Implementovatelná UC pro fázi F3 — uživatel si vytváří a spravuje prostředí (kind=USER_CREATED, resource_mode=SHARED). Staví na Foundation F1 (UC-10010) a Billing read-path F2 (UC-10012).
- Každé nové prostředí obdrží vlastní K8s namespace
{tenant}-{env-slug}provisionovaný přes novýEnvironmentProvisionerwrapper (interface + K8s impl + Noop; vzor ADR-014/ADR-015). - Prostředí DEFAULT „TalkIDE” (
deletable=false) nelze smazat — hardcoded invariant BE i FE. PROJECT.environment_id(nový nullable FK) váže projekt na cílové prostředí nasazení. F1/F2 projekty bez volby =NULL→ implicitně fallback na DEFAULT „TalkIDE”.- OD-3 (LOCKED): nové prostředí vždy
resource_mode=SHARED. DEDICATED je gated post-alfa F5. - OD-2 (LOCKED): F3 se nedotýká žádného enforcement, suspendu ani scale-to-zero. To je F4.
todo-list.talkide.app(popelkam) a DEFAULT namespacetenant-popelkamjsou nedotčeny.- Related: ADR-026, UC-10010 F1, UC-10011 F3 scope, UC-10012 F2
Odchylky implementace / rozhodnutí
FE umístění Environments — přesun z ProfileScreen na Studio screen +
/environments(ADR-026 rovnocennost).Původní návrh v UC-10013 (první verze) umísťoval správu prostředí do
ProfileScreen.vuejako záložku?section=environmentsv nastavení profilu. Toto umístění nesedí s principem ADR-026, který definuje Environment jako first-class entitu rovnocennou Projectu, nikoliv jako nastavení uživatele.Nové umístění (tato verze UC):
- Studio screen (
/studio) — blok „Environments” pod sekcí projektů, vizuálně menší kompaktní karty (EnvironmentCard), odkaz „Zobrazit vše (N)” →/environments.- Samostatná stránka
/environments(EnvironmentsScreen.vue) — plnohodnotná správa (seznam, Create, Delete). Přebírá funkcionalitu původníEnvironmentsSection.vue.ProfileScreen.vue— záložkaenvironmentsa položka v sidebar nav se odstraňuje.Odchylka je záměrná, zdůvodněná ADR-026 a FE architekturou (vzor: Projects má vlastní
/projectsroute i blok na Studio screen — Environments kopíruje stejný vzor).
Lazy-vs-eager provisioning tenze (ADR-026 §4 vs. UC-10011 F3 scope).
ADR-026 §4 (ř. 197–199) říká: „Provisioning je lazy (vzniká při prvním nasazení projektu do prostředí, NE při Create Environment)”.
UC-10011 F3 scope (acceptance kritérium 1) říká opačně: „User vytvoří prostředí TEST → vznikne namespace
{tenant}-test” — tedy eager provisioning při Create Environment.Tato UC se přiklání k eager provisioning (provision AT Create) z následujících důvodů:
- Acceptance kritérium F3 je explicitně formulováno jako eager a slouží jako testovací cíl před F4.
- Eager provisioning dává uživateli okamžitou zpětnou vazbu — namespace buď vznikne a Create uspěje, nebo celá operace selže atomicky (
@Transactionalrollback).- Lazy provisioning v F3 by zkomplikoval chybové scénáře F4 (namespace-cut-over), kde je nutné mít namespace garantovaně připravený před nasazením projektu.
- ADR-026 §4 byl pravděpodobně napsán s výhledem na DEDICATED prostředí (těžký provisioning) — pro SHARED namespace je eager řešením s nulovými náklady.
Odchylka od ADR-026 §4 je záměrná a omezená na SHARED USER_CREATED prostředí v F3. Pro DEDICATED (F5) platí původní lazy/async vzor z ADR-026 §4. ADR-026 může být aktualizován PR autorem F3 po implementaci.
Přehled deliverables F3
| Deliverable | Endpoint / komponenta | Stav |
|---|---|---|
| Create Environment | POST /api/v1/environments | Nové |
| Get Environment detail | GET /api/v1/environments/{id} | Nové |
| List Environments | GET /api/v1/environments | Rozšíření F1 (již existuje) |
| Delete Environment | DELETE /api/v1/environments/{id} | Nové |
EnvironmentProvisioner wrapper | Interface + K8s impl + Noop | Nová infrakomponenta |
projects.environment_id FK | Liquibase 0040 | DB migrace |
| FE Environment management | Studio blok + /environments (EnvironmentsScreen) | Nová FE stránka + Studio widget |
| Create Project — env selector | Volitelný environmentId v request | Rozšíření |
Datový model a changesety
Mermaid ER (delta F3 — dotčené entity a vazby)
erDiagram
TENANT {
bigint id
string name
string slug
bigint owner_id
}
ENVIRONMENT {
bigint id
bigint tenant_id
string kind
string name
string slug
string resource_mode
string status
boolean deletable
string namespace_ref
jsonb config
timestamp created_at
timestamp updated_at
}
PROJECT {
bigint id
bigint tenant_id
bigint environment_id
string name
string status
string slug
}
TENANT ||--o{ ENVIRONMENT : has
TENANT ||--o{ PROJECT : has
PROJECT }o--o| ENVIRONMENT : deployed_to
projects.environment_id je nullable — NULL znamená „nasadit do DEFAULT prostředí tenanta” (F1/F2 projekty bez explicitní volby). Explicitní FK na konkrétní environment(id) se nastavuje při Create Project (pokud user vybere prostředí) nebo může být NULL po smazání prostředí (fallback na DEFAULT).
Nové Liquibase changesety (F3)
| Soubor | Obsah | Rollback |
|---|---|---|
0040-add-environment-id-to-projects.xml | ALTER TABLE projects ADD COLUMN environment_id BIGINT NULL REFERENCES environment(id) ON DELETE SET NULL + index | ALTER TABLE projects DROP COLUMN environment_id |
Changeset 0040 je jediný DB changeset pro F3. ON DELETE SET NULL zajistí fallback na DEFAULT při smazání prostředí (viz Delete Environment flow).
<?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="0040-add-environment-id-to-projects" author="talkide">
<addColumn tableName="projects">
<column name="environment_id" type="BIGINT">
<constraints nullable="true"
foreignKeyName="fk_projects_environment"
references="environment(id)"
deleteCascade="false"/>
</column>
</addColumn>
<!-- ON DELETE SET NULL: při smazání prostředí se projekt fallbackuje na DEFAULT -->
<sql>
ALTER TABLE projects
DROP CONSTRAINT IF EXISTS fk_projects_environment;
ALTER TABLE projects
ADD CONSTRAINT fk_projects_environment
FOREIGN KEY (environment_id)
REFERENCES environment(id)
ON DELETE SET NULL;
</sql>
<!-- Index pro rychlé vyhledávání projektů v daném prostředí -->
<createIndex tableName="projects" indexName="idx_projects_environment_id">
<column name="environment_id"/>
</createIndex>
<rollback>
<dropIndex tableName="projects" indexName="idx_projects_environment_id"/>
<dropColumn tableName="projects" columnName="environment_id"/>
</rollback>
</changeSet>
</databaseChangeLog>
Registrace v db.changelog-master.xml (přidat za 0039-add-hosting-spending-limit-to-user-budget.xml):
<include file="changes/0040-add-environment-id-to-projects.xml" relativeToChangelogFile="true"/>
EnvironmentProvisioner — nový wrapper (ADR-014/ADR-015 vzor)
Existující NamespaceProvisioner je hardcoded na tenant-{slug} (metoda provisionTenantNamespace(tenantId: Long)). Pro F3 je potřeba provisionovat libovolný namespace {tenant}-{env-slug}. Proto vzniká nový wrapper EnvironmentProvisioner s jiným podpisem.
Interface
package com.mddsummer.talkide.features.environment.infra
/**
* ADR-026 F3 — provisioning K8s namespace pro user-created prostředí.
*
* Nový wrapper separátní od [NamespaceProvisioner] (ten je hardcoded na
* `tenant-{slug}` a slouží Create Project). EnvironmentProvisioner provisionuje
* libovolný `namespaceName` předaný volajícím.
*
* Implementace:
* - [K8sEnvironmentProvisioner] — produkční impl (talkide.k8s.enabled=true)
* - [NoopEnvironmentProvisioner] — dev/test fallback
*
* Vzor (ADR-014/ADR-015): idempotentní get-or-create, failure → výjimka →
* @Transactional rollback na use-case úrovni.
*/
interface EnvironmentProvisioner {
/**
* Provisionuje K8s namespace [namespaceName] pro prostředí. Idempotentní.
* Vytvoří namespace + ResourceQuota + LimitRange (sdílené kvóty).
* Failure → výjimka → rollback DB insertů v @Transactional.
*
* @param namespaceName Cílový namespace, např. `popelkam-stage1`
* @param tenantId Tenant ID pro labels
* @param environmentId Environment ID pro labels
* @throws io.fabric8.kubernetes.client.KubernetesClientException při K8s API chybě
*/
fun provisionEnvironmentNamespace(namespaceName: String, tenantId: Long, environmentId: Long)
/**
* Deprovisionuje namespace — smaže namespace a všechny K8s objekty v něm.
* Volá se z DeleteEnvironmentUseCase. Failure → TeardownFailedException (catch v use-case).
*
* @param namespaceName Namespace ke smazání
*/
fun deprovisionEnvironmentNamespace(namespaceName: String)
}
Noop implementace
@Component
@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "false", matchIfMissing = true)
class NoopEnvironmentProvisioner : EnvironmentProvisioner {
private val log = LoggerFactory.getLogger(javaClass)
override fun provisionEnvironmentNamespace(namespaceName: String, tenantId: Long, environmentId: Long) {
log.info("NoopEnvironmentProvisioner.provisionEnvironmentNamespace({}) — K8s disabled", namespaceName)
}
override fun deprovisionEnvironmentNamespace(namespaceName: String) {
log.info("NoopEnvironmentProvisioner.deprovisionEnvironmentNamespace({}) — K8s disabled", namespaceName)
}
}
K8s implementace (klíčové body)
@Component
@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "true")
class K8sEnvironmentProvisioner(
private val client: KubernetesClient,
private val properties: TalkideProperties,
) : EnvironmentProvisioner {
override fun provisionEnvironmentNamespace(namespaceName: String, tenantId: Long, environmentId: Long) {
// 1. Namespace (idempotentní get-or-create)
ensureNamespace(namespaceName, tenantId, environmentId)
// 2. ResourceQuota
ensureResourceQuota(namespaceName)
// 3. LimitRange
ensureLimitRange(namespaceName)
// Poznámka: registry secrets, TLS secret a workspace PVC
// se provisionují lazy při prvním Publish do tohoto prostředí (F4 scope).
// Pro F3 stačí namespace + kvóty = prostředí je použitelné pro billing attribution.
}
override fun deprovisionEnvironmentNamespace(namespaceName: String) {
val existing = client.namespaces().withName(namespaceName).get()
if (existing == null) {
log.warn("deprovisionEnvironmentNamespace: namespace '{}' not found — treating as already gone", namespaceName)
return
}
client.namespaces().withName(namespaceName).delete()
log.info("Deleted K8s namespace '{}'", namespaceName)
}
// ensureNamespace, ensureResourceQuota, ensureLimitRange — stejný vzor
// jako K8sNamespaceProvisioner (viz reálný kód), ale s label
// talkide.app/environment-id = environmentId.toString()
}
API kontrakty
1. Create Environment — POST /api/v1/environments
sequenceDiagram
actor User
participant FE
participant BE as BE (CreateEnvironmentUseCase)
participant EP as EnvironmentProvisioner
participant DB
User->>+FE: vyplní formulář (name, slug)
FE->>FE: validace slug RFC-1123, délka, reserved list
alt form nevalidní
FE-->>User: zobraz chybové hlášky
end
User->>+FE: odešle formulář
FE->>+BE: POST /api/v1/environments <br> CreateEnvironmentRequest
BE->>BE: validate request (slug RFC-1123, ≤20 znaků, not reserved)
alt slug neplatný nebo reserved
BE-->>FE: 400 Bad Request <br> ErrorResponse (VALIDATION_ERROR)
end
BE->>+DB: SELECT 1 FROM environment WHERE tenant_id=? AND slug=?
alt slug již existuje v rámci tenanta
DB-->>BE: exists
BE-->>FE: 409 Conflict <br> ErrorResponse (CONFLICT_ENVIRONMENT_SLUG)
end
DB-->>-BE: not found
BE->>+DB: INSERT INTO environment (tenant_id, kind, name, slug, resource_mode, status, deletable, namespace_ref) <br> VALUES (?, 'USER_CREATED', ?, ?, 'SHARED', 'ACTIVE', true, null)
DB-->>-BE: environment (id)
BE->>+EP: provisionEnvironmentNamespace("{tenant}-{env-slug}", tenantId, envId)
alt provisioning selže (K8s API error)
EP-->>BE: KubernetesClientException
BE->>DB: @Transactional ROLLBACK (environment INSERT zrušen)
BE-->>FE: 503 Service Unavailable <br> ErrorResponse (ENVIRONMENT_PROVISIONING_FAILED)
end
EP-->>-BE: OK
BE->>+DB: UPDATE environment SET namespace_ref='{tenant}-{env-slug}', updated_at=NOW() WHERE id=?
DB-->>-BE: OK
BE-->>-FE: 201 Created <br> EnvironmentDto
FE-->>-User: prostředí vytvořeno, přesměrování na detail
POST /api/v1/environments CreateEnvironmentRequest:
{
"name": "Stage 1",
"slug": "stage1"
}
201 Created EnvironmentDto:
{
"id": 2,
"tenantId": 42,
"kind": "USER_CREATED",
"name": "Stage 1",
"slug": "stage1",
"resourceMode": "SHARED",
"status": "ACTIVE",
"deletable": true,
"namespaceRef": "popelkam-stage1",
"createdAt": "2026-05-19T10:00:00",
"updatedAt": "2026-05-19T10:00:00"
}
400 Bad Request (validace slug) ErrorResponse:
{
"code": "VALIDATION_ERROR",
"message": "Bad request"
}
409 Conflict (slug již existuje v rámci tenanta) ErrorResponse:
{
"code": "CONFLICT_ENVIRONMENT_SLUG",
"message": "Environment with this slug already exists"
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
503 Service Unavailable (provisioning K8s namespace selhal) ErrorResponse:
{
"code": "ENVIRONMENT_PROVISIONING_FAILED",
"message": "Failed to provision environment namespace"
}
2. Get Environment detail — GET /api/v1/environments/{id}
sequenceDiagram
actor User
participant FE
participant BE as BE (GetEnvironmentUseCase)
participant DB
User->>+FE: zobrazí detail prostředí
FE->>+BE: GET /api/v1/environments/{id}
BE->>BE: getCurrentTenantId() z JWT
BE->>+DB: SELECT * FROM environment WHERE id=?
alt prostředí nenalezeno
DB-->>BE: 0 rows
BE-->>FE: 404 Not Found <br> ErrorResponse (NOT_FOUND)
end
DB-->>-BE: environment row
BE->>BE: ověří environment.tenantId == currentTenantId
alt jiný tenant
BE-->>FE: 403 Forbidden <br> ErrorResponse (FORBIDDEN)
end
BE-->>-FE: 200 OK <br> EnvironmentDto
FE-->>-User: zobrazí detail
GET /api/v1/environments/{id}
200 OK EnvironmentDto:
{
"id": 2,
"tenantId": 42,
"kind": "USER_CREATED",
"name": "Stage 1",
"slug": "stage1",
"resourceMode": "SHARED",
"status": "ACTIVE",
"deletable": true,
"namespaceRef": "popelkam-stage1",
"createdAt": "2026-05-19T10:00:00",
"updatedAt": "2026-05-19T10:00:00"
}
404 Not Found ErrorResponse:
{
"code": "NOT_FOUND",
"message": "Environment not found"
}
403 Forbidden ErrorResponse:
{
"code": "FORBIDDEN",
"message": "Access denied"
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
3. List Environments — GET /api/v1/environments
Rozšíření existujícího F1 endpointu — v F3 může vrátit N prostředí (DEFAULT + user-created).
GET /api/v1/environments
200 OK EnvironmentListResponse:
{
"environments": [
{
"id": 1,
"tenantId": 42,
"kind": "DEFAULT",
"name": "TalkIDE",
"slug": "talkide",
"resourceMode": "SHARED",
"status": "ACTIVE",
"deletable": false,
"namespaceRef": "tenant-popelkam",
"createdAt": "2026-05-19T09:00:00",
"updatedAt": "2026-05-19T09:00:00"
},
{
"id": 2,
"tenantId": 42,
"kind": "USER_CREATED",
"name": "Stage 1",
"slug": "stage1",
"resourceMode": "SHARED",
"status": "ACTIVE",
"deletable": true,
"namespaceRef": "popelkam-stage1",
"createdAt": "2026-05-19T10:00:00",
"updatedAt": "2026-05-19T10:00:00"
}
]
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
4. Delete Environment — DELETE /api/v1/environments/{id}
sequenceDiagram
actor User
participant FE
participant BE as BE (DeleteEnvironmentUseCase)
participant EP as EnvironmentProvisioner
participant DB
User->>+FE: klikne Delete na prostředí
FE->>FE: ověří deletable==true (guard na FE)
alt deletable==false
FE-->>User: Delete button disabled / chybová hláška
end
FE->>+BE: DELETE /api/v1/environments/{id}
BE->>BE: getCurrentTenantId() z JWT
BE->>+DB: SELECT * FROM environment WHERE id=?
alt nenalezeno
DB-->>BE: 0 rows
BE-->>FE: 404 Not Found <br> ErrorResponse (NOT_FOUND)
end
DB-->>-BE: environment row
BE->>BE: ověří tenantId ownership
alt jiný tenant
BE-->>FE: 403 Forbidden
end
BE->>BE: ověří environment.deletable
alt deletable==false (DEFAULT „TalkIDE")
BE-->>FE: 409 Conflict <br> ErrorResponse (CONFLICT_ENVIRONMENT_NOT_DELETABLE)
end
BE->>+DB: UPDATE environment SET status='DEPROVISIONING', updated_at=NOW() WHERE id=?
DB-->>-BE: OK
BE->>+EP: deprovisionEnvironmentNamespace(environment.namespaceRef)
alt deprovision selže
EP-->>BE: exception
Note over BE: TeardownFailedException — namespace možná ve špatném stavu
BE->>DB: UPDATE environment SET status='ACTIVE' (rollback status)
BE-->>FE: 503 Service Unavailable <br> ErrorResponse (ENVIRONMENT_DEPROVISION_FAILED)
end
EP-->>-BE: OK
Note over DB: ON DELETE SET NULL na fk_projects_environment<br/>= projects.environment_id → NULL (automaticky DB)
BE->>+DB: DELETE FROM environment WHERE id=?
DB-->>-BE: OK
BE-->>-FE: 204 No Content
FE-->>-User: prostředí smazáno, seznam refreshnutý
DELETE /api/v1/environments/{id}
204 No Content (úspěch)
409 Conflict (DEFAULT prostředí — nelze smazat) ErrorResponse:
{
"code": "CONFLICT_ENVIRONMENT_NOT_DELETABLE",
"message": "This environment cannot be deleted"
}
404 Not Found ErrorResponse:
{
"code": "NOT_FOUND",
"message": "Environment not found"
}
403 Forbidden ErrorResponse:
{
"code": "FORBIDDEN",
"message": "Access denied"
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
503 Service Unavailable (deprovision namespace selhal) ErrorResponse:
{
"code": "ENVIRONMENT_DEPROVISION_FAILED",
"message": "Failed to deprovision environment namespace"
}
5. Create Project — volitelný env selector
Existující POST /api/v1/projects rozšiřuje request o volitelný environmentId. Pokud není uveden nebo je null, projekt se váže na DEFAULT prostředí tenanta (get-or-create, stejný vzor jako F1).
POST /api/v1/projects CreateProjectRequest (delta F3):
{
"name": "My App",
"description": "Optional description",
"environmentId": 2
}
Pole environmentId je volitelné (null = DEFAULT). Pokud je uvedeno, BE ověří:
- Prostředí existuje.
- Prostředí patří stejnému tenantovi.
- Prostředí má
status=ACTIVE.
Při nesplnění bodů 1–2 (prostředí neexistuje nebo patří jinému tenantovi) → 400 VALIDATION_ERROR. Při nesplnění bodu 3 (prostředí existuje, patří tenantovi, ale není ACTIVE) → 409 CONFLICT_ENVIRONMENT_NOT_ACTIVE.
Frontend
Umístění Environments v UI (ADR-026 first-class vzor)
Environments kopírují stejný FE vzor jako Projects: blok na Studio screen + samostatná stránka. Toto odpovídá reálné struktuře FE (src/screens/).
1. Studio screen — blok „Environments” pod sekcí projektů
Komponenta/soubor: src/screens/studio/StudioScreen.vue
Pod stávající sekcí „Your Projects” (grid ProjectCard komponent) přibude nový blok se stejnou vizuální strukturou jako sekce Issues widget nebo Projects header:
// Vzor převzatý z existujícího StudioScreen.vue:
<div class="flex items-baseline justify-between mb-4">
<h2 class="text-[13px] font-mono uppercase font-medium text-[var(--fg-3)] m-0" style="letter-spacing: 0.08em;">
Environments
</h2>
<button ... @click="router.push({ name: 'environments' })">
Zobrazit vše (N) <ArrowRight :size="13" />
</button>
</div>
- Zobrazuje max. 3 prostředí (stejný limit jako ProjectCard grid).
- Karty prostředí jsou vizuálně kompaktnější než
ProjectCard— bez thumbnail, menší padding (vzorpx-4 py-3jako řádky vEnvironmentsSection.vue), stejný design jazyk (rounded-[var(--r-lg)] bg-[var(--bg-2)] border border-[var(--line-1)]). - Doporučená komponenta:
src/screens/studio/components/EnvironmentCard.vue(nová, lehká varianta pro Studio blok). - Odkaz „Zobrazit vše (N)” používá
router.push({ name: 'environments' })a zobrazuje celkový count (environmentStore.total). Renderuje se vždy (i pro 0 prostředí, kde N = 0 — user vždy vidí cestu do správy). onMountedvStudioScreen.vuepřidáenvironmentStore.fetchEnvironments()(fire-and-forget, stejný vzor jakoprojectStore.fetchProjects).
2. Samostatná stránka /environments — EnvironmentsScreen.vue
Route: /environments, name: environments, meta: { requiresAuth: true }
Soubor: src/screens/environments/EnvironmentsScreen.vue (nový)
Přebírá a obaluje existující EnvironmentsSection.vue (src/screens/environments/components/EnvironmentsSection.vue) — ta obsahuje kompletní logiku (list, Create formulář via CreateEnvironmentForm.vue, Delete dialog, store volání). EnvironmentsScreen.vue přidá jen layout wrapper s TTopBar (stejný vzor jako ProjectsScreen.vue).
Router entry (přidat do src/common/router/index.ts):
{
path: '/environments',
name: 'environments',
component: () => import('@/screens/environments/EnvironmentsScreen.vue'),
meta: { requiresAuth: true },
},
3. Odstranění z ProfileScreen — zrušení ?section=environments
Soubor: src/screens/profile/ProfileScreen.vue
- Ze seznamu
VALID_SECTIONSse odstraní'environments'. - Z pole
SECTIONS(computed) se odstraní položka{ id: 'environments', label: ..., icon: Layers }a importLayersz lucide-vue-next. - Import
EnvironmentsSectionz@/screens/environments/components/EnvironmentsSection.vuese odstraní. <EnvironmentsSection v-if="activeSection === 'environments'" />se odstraní z template.- Environments již není nastavení — je to first-class entita (ADR-026).
Create Environment formulář
Komponenta: src/screens/environments/components/CreateEnvironmentForm.vue (existuje, beze změny)
| Pole | Label | Typ | Placeholder | Popis |
|---|---|---|---|---|
name | Name | text | „Stage 1”, „QA” | Zobrazovaný název prostředí |
slug | Slug | text | „stage1” | URL-friendly identifikátor (RFC-1123, nesmí být reserved — test/prod/talkide apod. jsou zakázané) |
FE auto-generuje slug z name (lowercase, mezery → pomlčky). User může slug editovat manuálně.
FE Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
name | not_blank | 1–100 | Zobrazovaný název | |
slug | not_blank | 1–20 | ^[a-z0-9][a-z0-9-]*[a-z0-9]$ nebo jednoznakový [a-z0-9] | RFC-1123 lowercase |
slug | not reserved | Zobraz chybu pokud uživatel zadá talkide, www, api, prod, atd. (viz reserved list níže) |
Reserved slug list (FE whitelist check — klient-side pre-validation):
talkide, www, api, app, auth, admin, console, dashboard, mail, smtp, imap, mx, mx1, mx2, ftp, ssh, vpn, static, cdn, assets, media, images, files, downloads, docs, help, support, status, blog, news, about, terms, privacy, legal, test, dev, staging, prod, production, internal, beta, alpha, login, signup, register, logout
Backend
Validations — Create Environment
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
name | not_blank | 1–100 | ||
slug | not_blank | 1–20 | ^[a-z0-9][a-z0-9-]*[a-z0-9]$\|^[a-z0-9]$ | RFC-1123, lowercase, no trailing/leading dash |
slug | not reserved | Viz EnvironmentSlugValidator.RESERVED_SLUGS | ||
slug | unique in tenant | existsByTenantIdAndSlug check; conflict → 409 | ||
{tenant}-{slug} délka | ≤63 | K8s namespace limit; viz invariant níže |
Invariant délky namespace: {tenantSlug}-{envSlug}.length ≤ 63. Tenant slug má typicky 5–30 znaků; env slug max 20. Celková délka {tenant}-{env-slug} = len(tenantSlug) + 1 + len(envSlug). Pokud tenantSlug je dlouhý, BE musí odmítnout s validační chybou VALIDATION_ERROR (pole slug, zpráva: „Combined namespace name would exceed 63 characters”).
Kompletní reserved list (BE EnvironmentSlugValidator):
talkide, www, api, app, auth, admin, console, dashboard, mail, smtp, imap, mx, mx1, mx2, ftp, ssh, vpn, static, cdn, assets, media, images, files, downloads, docs, help, support, status, blog, news, about, terms, privacy, legal, test, dev, staging, prod, production, internal, beta, alpha, login, signup, register, logout
Validations — Delete Environment
| Check | Zdroj | Chyba |
|---|---|---|
| Environment existuje | DB lookup by id | 404 NOT_FOUND |
| Tenant ownership | env.tenantId == currentTenantId | 403 FORBIDDEN |
deletable == true | env.deletable | 409 CONFLICT_ENVIRONMENT_NOT_DELETABLE |
Klíčové backend komponenty F3
| Komponenta | Popis |
|---|---|
EnvironmentProvisioner (interface) | Nový wrapper — viz sekce výše |
K8sEnvironmentProvisioner | Prod implementace (fabric8, @ConditionalOnProperty talkide.k8s.enabled=true) |
NoopEnvironmentProvisioner | Dev/test fallback (matchIfMissing = true) |
CreateEnvironmentUseCase | @Transactional — INSERT + provisionEnvironmentNamespace + UPDATE namespace_ref; rollback při KubernetesClientException |
GetEnvironmentUseCase | Readonly — lookup by id + tenant ownership check |
DeleteEnvironmentUseCase | UPDATE status=DEPROVISIONING + deprovisionEnvironmentNamespace + DELETE; rollback status při deprovision failure |
EnvironmentSlugValidator | Centralizovaný validátor — RFC-1123, reserved list, délkový limit |
ProjectEntity.environmentId | Nový nullable Long? sloupec (changeset 0040) |
Nové
ErrorCodeenum hodnoty (implementátor MUSÍ přidat docom.mddsummer.talkide.common.exception.model.ErrorCode): reálnýErrorCode.kttyto kódy zatím nemá — F3 je zavádí:
CONFLICT_ENVIRONMENT_SLUG(“Environment with this slug already exists”)CONFLICT_ENVIRONMENT_NOT_DELETABLE(“This environment cannot be deleted”)ENVIRONMENT_PROVISIONING_FAILED(“Failed to provision environment namespace”)ENVIRONMENT_DEPROVISION_FAILED(“Failed to deprovision environment namespace”)CONFLICT_ENVIRONMENT_NOT_ACTIVE(“Target environment is not active”)Validační chyby env-slug (RFC-1123, reserved, délka, combined ≤63) používají existující
VALIDATION_ERROR(ne nový kód).
Implementační poznámka —
EnvironmentDto.updatedAt(kód máLocalDateTime?):EnvironmentEntitycompanioncreate()/ Create flow nastavujeupdated_at = created_atuž při insertu (stejný vzor jako F1createDefault()). ProtoupdatedAtv JSON kontraktech níže je vždy non-null (=createdAtpři vytvoření, aktualizováno při každém UPDATE např. status change). JSON příklady tedy zůstávají platné a kontrakt čistý —updatedAtse nikdy nevracínull.
Slug validátor — klíčový kód
object EnvironmentSlugValidator {
val RESERVED_SLUGS = setOf(
"talkide", "www", "api", "app", "auth", "admin", "console", "dashboard",
"mail", "smtp", "imap", "mx", "mx1", "mx2", "ftp", "ssh", "vpn",
"static", "cdn", "assets", "media", "images", "files", "downloads",
"docs", "help", "support", "status", "blog", "news", "about", "terms",
"privacy", "legal", "test", "dev", "staging", "prod", "production",
"internal", "beta", "alpha", "login", "signup", "register", "logout"
)
private val RFC1123_PATTERN = Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
/**
* Validates env-slug: RFC-1123, ≤20 chars, not reserved, combined ns ≤63.
* @throws BadRequestException on validation failure
*/
fun validate(slug: String, tenantSlug: String) {
require(slug.length in 1..20) { "slug must be 1–20 characters" }
require(RFC1123_PATTERN.matches(slug)) { "slug must match RFC-1123 (lowercase, alphanumeric, dashes)" }
require(slug !in RESERVED_SLUGS) { "slug '$slug' is reserved" }
val nsName = "$tenantSlug-$slug"
require(nsName.length <= 63) { "Combined namespace name '$nsName' exceeds 63 characters (K8s limit)" }
}
}
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Autentizovaný tenant, slug „stage1” dostupný | POST /api/v1/environments { name: "Stage 1", slug: "stage1" } | 201 Created; environment row v DB s kind=USER_CREATED, status=ACTIVE, namespace_ref=popelkam-stage1; namespace popelkam-stage1 provisionován |
| Autentizovaný tenant, slug „stage1” již existuje u téhož tenanta | POST /api/v1/environments { name: "Stage 1 dup", slug: "stage1" } | 409 CONFLICT_ENVIRONMENT_SLUG |
Autentizovaný tenant, slug prod (reserved) | POST /api/v1/environments { name: "PROD", slug: "prod" } | 400 VALIDATION_ERROR (slug reserved) |
Autentizovaný tenant, slug talkide (reserved — default) | POST /api/v1/environments { name: "X", slug: "talkide" } | 400 VALIDATION_ERROR (slug reserved) |
Autentizovaný tenant, slug test-env-very-long-name-that-exceeds-limit (>20 znaků) | POST /api/v1/environments { slug: "..." } | 400 VALIDATION_ERROR (slug too long) |
| Tenant se slugem 40+ znaků, env-slug 20 znaků (combined >63) | POST /api/v1/environments { slug: "envslug20charslength" } | 400 VALIDATION_ERROR (combined ns name too long) |
Slug s velkými písmeny TEST | POST /api/v1/environments { slug: "TEST" } | 400 VALIDATION_ERROR (not RFC-1123 lowercase) |
Slug se začínající pomlčkou -test | POST /api/v1/environments { slug: "-test" } | 400 VALIDATION_ERROR (RFC-1123 violation) |
| K8s provisioner vrátí KubernetesClientException | POST /api/v1/environments (K8s nedostupný) | 503 ENVIRONMENT_PROVISIONING_FAILED; žádný environment řádek v DB (rollback) |
| Autentizovaný tenant | GET /api/v1/environments (má DEFAULT + 1 USER_CREATED) | 200 OK; seznam 2 prostředí |
| Autentizovaný tenant | GET /api/v1/environments/{existingId} | 200 OK; EnvironmentDto |
| Autentizovaný tenant, id neexistuje | GET /api/v1/environments/{nonExistentId} | 404 NOT_FOUND |
| Autentizovaný tenant, id patří jinému tenantovi | GET /api/v1/environments/{otherTenantEnvId} | 403 FORBIDDEN |
USER_CREATED prostředí s deletable=true, 2 projekty na tohoto prostředí | DELETE /api/v1/environments/{id} | 204 No Content; namespace deprovisionován; environment smazán z DB; projects.environment_id na NULL (ON DELETE SET NULL) |
DEFAULT prostředí „TalkIDE” (deletable=false) | DELETE /api/v1/environments/{defaultId} | 409 CONFLICT_ENVIRONMENT_NOT_DELETABLE |
| USER_CREATED prostředí, deprovision selže | DELETE /api/v1/environments/{id} (K8s error) | 503 ENVIRONMENT_DEPROVISION_FAILED; environment status vrácen na ACTIVE; namespace stav nedefinovaný (ops cleanup potřeba) |
| Neautentizovaný požadavek | Jakýkoli GET/POST/DELETE /api/v1/environments | 401 AUTHENTICATION_FAILED |
Create Project s platným environmentId (USER_CREATED, ACTIVE, same tenant) | POST /api/v1/projects { environmentId: 2, ... } | 201 Created; projects.environment_id = 2 |
Create Project bez environmentId (null) | POST /api/v1/projects { ... } | 201 Created; projects.environment_id = NULL (fallback DEFAULT) |
Create Project s environmentId prostředí jiného tenanta | POST /api/v1/projects { environmentId: 99, ... } | 400 VALIDATION_ERROR (env-id neexistuje v tenant scope = validační chyba requestu) |
GET /api/v1/environments — Noop provisioner (lokální dev) | Create + List | 201 + 200 OK; namespace_ref nastaven; žádný K8s API call |
Acceptance kritéria F3 (mapování na Test Cases)
| Acceptance kritérium (z UC-10011) | Pokryto test cases |
|---|---|
1. User vytvoří prostředí „TEST” (SHARED) → vznikne namespace {tenant}-test s ResourceQuota; environment záznam s namespace_ref | TC: Create happy path, K8s provisioner |
| 2. Nasazení projektu do „TEST” funguje (Publish target volba v UI) | TC: Create Project s environmentId; FE env selector |
3. Delete „TEST” → namespace deprovisionován; environment DEPROVISIONING → smazán; projekt bez deployment-targetu fallback na DEFAULT | TC: Delete USER_CREATED + ON DELETE SET NULL |
| 4. Delete DEFAULT „TalkIDE” → 409 CONFLICT_ENVIRONMENT_NOT_DELETABLE | TC: Delete DEFAULT |
| 5. env-slug validace: rezervované slugy → 400/409 | TC: reserved slug, velikost písma, délka, unikátnost |
6. todo-list.talkide.app a DEFAULT environment nedotčeny | Changeset 0040 je additivní nullable FK; DEFAULT env a tenant-popelkam namespace beze změny |
| 7. Faktura (F2) ukáže 2 řádky při aktivním TEST prostředí | Není přímý test case v F3; F2 billing automaticky zachytí nový namespace (OpenCost aggregate=namespace) |
FEEDBACK
Chyběla mi znalost reálného EnvironmentDto.createdAt/updatedAt typu — kód používá LocalDateTime (ne Instant), což jsem zjistil až z DTOs, ne z dokumentace; bylo by užitečné mít tento detail v F1 UC. Dále bych uvítal předpřipravený “change catalog” existujících changeset čísel přímo v zadání (nemusel bych ls changesety), nebo aspoň potvrzení nejvyššího čísla. Celkově by implementaci pomohla šablona EnvironmentProvisioner interface s jasným podpisem — tato design tenze (nový wrapper vs. rozšíření stávajícího) stála nejvíc analytické práce.
FE kontrakt update (tato revize): Reálná FE struktura odpovídala popisu — vzor Studio blok + samostatná route je přesně vzor, který FE již používá pro Projects i Issues. EnvironmentsSection.vue jako znovupoužitelná komponenta je čistý design (stačí ji obalit do EnvironmentsScreen.vue). Jediná věc, která chyběla v zadání: existence src/screens/environments/ adresáře s EnvironmentsSection.vue (bylo třeba přečíst FE kód, aby bylo jasné, že komponenta existuje a není třeba ji navrhovat od nuly).
Thanks for the feedback.