Skip to content

Commit 4bba36b

Browse files
committed
feat: add tests, health probes, PVCs, and project docs
- Add K8sBackend interface to decouple handlers from concrete k8s client - Add unit tests: 12 backend handler tests + 10 CLI tests (no cluster needed) - Add liveness/readiness health probes to all 6 deployment templates - Add PVC templates for MongoDB (mern) and PostgreSQL (go-api) with volume mounts - Add ApplyPVC to k8s client; wire into manifest apply order (pvc→quota→cm→svc→deploy→ingress) - Rewrite README with architecture diagram, problem statement, deployment guide, folder structure - Add docs/STRUCTURE.md: full annotated file tree + design decisions - Add docs/DEPLOYMENT.md: 3 deployment options, troubleshooting, CI/CD docs - Improve docs/demo.html: spinner animation, auto-loop, replay button, status bar - Remove stale empty top-level templates/ directory
1 parent 030fe2e commit 4bba36b

21 files changed

Lines changed: 2006 additions & 140 deletions

README.md

Lines changed: 332 additions & 132 deletions
Large diffs are not rendered by default.

backend/internal/api/handlers/environments.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
@@ -11,14 +12,26 @@ import (
1112

1213
"github.com/go-chi/chi/v5"
1314

14-
k8sclient "github.com/abhaychaurasiya/devctl-backend/internal/k8s"
1515
"github.com/abhaychaurasiya/devctl-backend/internal/models"
1616
"github.com/abhaychaurasiya/devctl-backend/internal/templates"
1717
)
1818

19+
// K8sBackend abstracts the Kubernetes operations used by handlers.
20+
type K8sBackend interface {
21+
CreateNamespace(ctx context.Context, name, username, template string, expiresAt time.Time) error
22+
DeleteNamespace(ctx context.Context, name string) error
23+
GetEnvironmentStatus(ctx context.Context, namespace string) (*models.EnvironmentStatusResponse, error)
24+
ApplyResourceQuota(ctx context.Context, namespace string, manifest []byte) error
25+
ApplyConfigMap(ctx context.Context, namespace string, manifest []byte) error
26+
ApplyService(ctx context.Context, namespace string, manifest []byte) error
27+
ApplyDeployment(ctx context.Context, namespace string, manifest []byte) error
28+
ApplyIngress(ctx context.Context, namespace string, manifest []byte) error
29+
ApplyPVC(ctx context.Context, namespace string, manifest []byte) error
30+
}
31+
1932
// Handler holds shared dependencies for all HTTP handlers.
2033
type Handler struct {
21-
K8s *k8sclient.Client
34+
K8s K8sBackend
2235
Loader *templates.Loader
2336
Renderer *templates.Renderer
2437
}
@@ -75,11 +88,16 @@ func (h *Handler) CreateEnvironment(w http.ResponseWriter, r *http.Request) {
7588
}
7689

7790
// Apply rendered manifests in dependency order:
78-
// resourcequota -> configmaps -> services -> deployments -> ingress
91+
// pvc -> resourcequota -> configmaps -> services -> deployments -> ingress
7992
for _, path := range orderedKeys(rendered) {
8093
manifest := rendered[path]
8194
base := filepath.Base(path)
8295
switch {
96+
case strings.Contains(base, "pvc"):
97+
if err := h.K8s.ApplyPVC(ctx, vars.Namespace, manifest); err != nil {
98+
http.Error(w, "failed to apply pvc: "+err.Error(), http.StatusInternalServerError)
99+
return
100+
}
83101
case strings.Contains(base, "resourcequota"):
84102
if err := h.K8s.ApplyResourceQuota(ctx, vars.Namespace, manifest); err != nil {
85103
http.Error(w, "failed to apply resourcequota: "+err.Error(), http.StatusInternalServerError)
@@ -176,10 +194,10 @@ func parseExpiry(ttl string) (time.Time, error) {
176194
return time.Now().Add(dur), nil
177195
}
178196

179-
// orderedKeys returns manifest paths sorted so resourcequotas and configmaps
180-
// are applied before services, deployments, and ingresses.
197+
// orderedKeys returns manifest paths sorted so pvcs and resourcequotas are
198+
// applied before configmaps, services, deployments, and ingresses.
181199
func orderedKeys(m map[string][]byte) []string {
182-
order := []string{"resourcequota", "configmap", "service", "deployment", "ingress"}
200+
order := []string{"pvc", "resourcequota", "configmap", "service", "deployment", "ingress"}
183201
var sorted []string
184202
for _, kind := range order {
185203
for k := range m {
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package handlers
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/go-chi/chi/v5"
12+
13+
"github.com/abhaychaurasiya/devctl-backend/internal/models"
14+
"github.com/abhaychaurasiya/devctl-backend/internal/templates"
15+
)
16+
17+
func newTestHandler(k8s K8sBackend) *Handler {
18+
loader := templates.NewLoader()
19+
return &Handler{
20+
K8s: k8s,
21+
Loader: loader,
22+
Renderer: templates.NewRenderer(loader),
23+
}
24+
}
25+
26+
func TestCreateEnvironment_Success(t *testing.T) {
27+
h := newTestHandler(&mockK8s{})
28+
29+
body, _ := json.Marshal(map[string]string{
30+
"template": "react",
31+
"username": "alice",
32+
"ttl": "30m",
33+
})
34+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader(body))
35+
req.Header.Set("Content-Type", "application/json")
36+
w := httptest.NewRecorder()
37+
38+
h.CreateEnvironment(w, req)
39+
40+
if w.Code != http.StatusAccepted {
41+
t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String())
42+
}
43+
44+
var resp models.CreateEnvResponse
45+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
46+
t.Fatalf("failed to decode response: %v", err)
47+
}
48+
if resp.Namespace != "dev-alice" {
49+
t.Errorf("expected namespace dev-alice, got %s", resp.Namespace)
50+
}
51+
if resp.URL != "http://alice-react.dev.local" {
52+
t.Errorf("expected URL http://alice-react.dev.local, got %s", resp.URL)
53+
}
54+
if resp.Status != string(models.StatusProvisioning) {
55+
t.Errorf("expected status Provisioning, got %s", resp.Status)
56+
}
57+
}
58+
59+
func TestCreateEnvironment_InvalidUsername(t *testing.T) {
60+
h := newTestHandler(&mockK8s{})
61+
62+
body, _ := json.Marshal(map[string]string{
63+
"template": "react",
64+
"username": "Alice_INVALID!",
65+
"ttl": "30m",
66+
})
67+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader(body))
68+
req.Header.Set("Content-Type", "application/json")
69+
w := httptest.NewRecorder()
70+
71+
h.CreateEnvironment(w, req)
72+
73+
if w.Code != http.StatusBadRequest {
74+
t.Errorf("expected 400, got %d", w.Code)
75+
}
76+
}
77+
78+
func TestCreateEnvironment_UnknownTemplate(t *testing.T) {
79+
h := newTestHandler(&mockK8s{})
80+
81+
body, _ := json.Marshal(map[string]string{
82+
"template": "nonexistent",
83+
"username": "bob",
84+
"ttl": "30m",
85+
})
86+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader(body))
87+
req.Header.Set("Content-Type", "application/json")
88+
w := httptest.NewRecorder()
89+
90+
h.CreateEnvironment(w, req)
91+
92+
if w.Code != http.StatusNotFound {
93+
t.Errorf("expected 404, got %d", w.Code)
94+
}
95+
}
96+
97+
func TestCreateEnvironment_InvalidTTL(t *testing.T) {
98+
h := newTestHandler(&mockK8s{})
99+
100+
body, _ := json.Marshal(map[string]string{
101+
"template": "react",
102+
"username": "alice",
103+
"ttl": "notaduration",
104+
})
105+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader(body))
106+
req.Header.Set("Content-Type", "application/json")
107+
w := httptest.NewRecorder()
108+
109+
h.CreateEnvironment(w, req)
110+
111+
if w.Code != http.StatusBadRequest {
112+
t.Errorf("expected 400, got %d", w.Code)
113+
}
114+
}
115+
116+
func TestCreateEnvironment_DefaultTTL(t *testing.T) {
117+
h := newTestHandler(&mockK8s{})
118+
119+
// No TTL field — should default to "30m" without error
120+
body, _ := json.Marshal(map[string]string{
121+
"template": "react",
122+
"username": "alice",
123+
})
124+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader(body))
125+
req.Header.Set("Content-Type", "application/json")
126+
w := httptest.NewRecorder()
127+
128+
h.CreateEnvironment(w, req)
129+
130+
if w.Code != http.StatusAccepted {
131+
t.Errorf("expected 202 with default TTL, got %d: %s", w.Code, w.Body.String())
132+
}
133+
}
134+
135+
func TestCreateEnvironment_InvalidBody(t *testing.T) {
136+
h := newTestHandler(&mockK8s{})
137+
138+
req := httptest.NewRequest(http.MethodPost, "/api/v1/environments", bytes.NewReader([]byte("notjson{")))
139+
req.Header.Set("Content-Type", "application/json")
140+
w := httptest.NewRecorder()
141+
142+
h.CreateEnvironment(w, req)
143+
144+
if w.Code != http.StatusBadRequest {
145+
t.Errorf("expected 400, got %d", w.Code)
146+
}
147+
}
148+
149+
func TestDeleteEnvironment_Success(t *testing.T) {
150+
h := newTestHandler(&mockK8s{})
151+
152+
r := chi.NewRouter()
153+
r.Delete("/api/v1/environments/{name}", h.DeleteEnvironment)
154+
155+
req := httptest.NewRequest(http.MethodDelete, "/api/v1/environments/dev-alice", nil)
156+
w := httptest.NewRecorder()
157+
r.ServeHTTP(w, req)
158+
159+
if w.Code != http.StatusNoContent {
160+
t.Errorf("expected 204, got %d", w.Code)
161+
}
162+
}
163+
164+
func TestGetEnvironmentStatus_Success(t *testing.T) {
165+
now := time.Now()
166+
statusResp := &models.EnvironmentStatusResponse{
167+
Phase: models.StatusReady,
168+
Services: []models.ServiceStatus{
169+
{Name: "react", Phase: "Running", Ready: true},
170+
},
171+
URL: "http://alice-react.dev.local",
172+
CreatedAt: now,
173+
ExpiresAt: now.Add(30 * time.Minute),
174+
}
175+
h := newTestHandler(&mockK8s{statusResp: statusResp})
176+
177+
r := chi.NewRouter()
178+
r.Get("/api/v1/environments/{name}/status", h.GetEnvironmentStatus)
179+
180+
req := httptest.NewRequest(http.MethodGet, "/api/v1/environments/alice-react/status", nil)
181+
w := httptest.NewRecorder()
182+
r.ServeHTTP(w, req)
183+
184+
if w.Code != http.StatusOK {
185+
t.Errorf("expected 200, got %d", w.Code)
186+
}
187+
188+
var resp models.EnvironmentStatusResponse
189+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
190+
t.Fatalf("failed to decode response: %v", err)
191+
}
192+
if resp.Phase != models.StatusReady {
193+
t.Errorf("expected phase Ready, got %s", resp.Phase)
194+
}
195+
if resp.URL != "http://alice-react.dev.local" {
196+
t.Errorf("unexpected URL: %s", resp.URL)
197+
}
198+
}
199+
200+
func TestNameToNamespace(t *testing.T) {
201+
cases := []struct {
202+
input string
203+
expected string
204+
}{
205+
{"dev-alice", "dev-alice"},
206+
{"alice-mern", "dev-alice"},
207+
{"bob-go-api", "dev-bob"},
208+
}
209+
for _, c := range cases {
210+
got := nameToNamespace(c.input)
211+
if got != c.expected {
212+
t.Errorf("nameToNamespace(%q) = %q, want %q", c.input, got, c.expected)
213+
}
214+
}
215+
}
216+
217+
func TestOrderedKeys(t *testing.T) {
218+
m := map[string][]byte{
219+
"templates/mern/ingress.yaml.tmpl": {},
220+
"templates/mern/mongodb/deployment.yaml.tmpl": {},
221+
"templates/mern/mongodb/pvc.yaml.tmpl": {},
222+
"templates/mern/resourcequota.yaml.tmpl": {},
223+
"templates/mern/express/configmap.yaml.tmpl": {},
224+
"templates/mern/express/service.yaml.tmpl": {},
225+
}
226+
227+
keys := orderedKeys(m)
228+
229+
// Build an index map for position lookup
230+
pos := make(map[string]int, len(keys))
231+
for i, k := range keys {
232+
pos[k] = i
233+
}
234+
235+
checks := []struct{ before, after string }{
236+
{"templates/mern/mongodb/pvc.yaml.tmpl", "templates/mern/resourcequota.yaml.tmpl"},
237+
{"templates/mern/resourcequota.yaml.tmpl", "templates/mern/express/configmap.yaml.tmpl"},
238+
{"templates/mern/express/configmap.yaml.tmpl", "templates/mern/express/service.yaml.tmpl"},
239+
{"templates/mern/express/service.yaml.tmpl", "templates/mern/mongodb/deployment.yaml.tmpl"},
240+
{"templates/mern/mongodb/deployment.yaml.tmpl", "templates/mern/ingress.yaml.tmpl"},
241+
}
242+
for _, c := range checks {
243+
if pos[c.before] >= pos[c.after] {
244+
t.Errorf("%q should come before %q in ordered keys", c.before, c.after)
245+
}
246+
}
247+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/abhaychaurasiya/devctl-backend/internal/models"
8+
)
9+
10+
// mockK8s is a test double for K8sBackend.
11+
type mockK8s struct {
12+
createNSErr error
13+
deleteNSErr error
14+
statusResp *models.EnvironmentStatusResponse
15+
statusErr error
16+
applyErr error
17+
}
18+
19+
func (m *mockK8s) CreateNamespace(_ context.Context, _, _, _ string, _ time.Time) error {
20+
return m.createNSErr
21+
}
22+
23+
func (m *mockK8s) DeleteNamespace(_ context.Context, _ string) error {
24+
return m.deleteNSErr
25+
}
26+
27+
func (m *mockK8s) GetEnvironmentStatus(_ context.Context, _ string) (*models.EnvironmentStatusResponse, error) {
28+
return m.statusResp, m.statusErr
29+
}
30+
31+
func (m *mockK8s) ApplyResourceQuota(_ context.Context, _ string, _ []byte) error { return m.applyErr }
32+
func (m *mockK8s) ApplyConfigMap(_ context.Context, _ string, _ []byte) error { return m.applyErr }
33+
func (m *mockK8s) ApplyService(_ context.Context, _ string, _ []byte) error { return m.applyErr }
34+
func (m *mockK8s) ApplyDeployment(_ context.Context, _ string, _ []byte) error { return m.applyErr }
35+
func (m *mockK8s) ApplyIngress(_ context.Context, _ string, _ []byte) error { return m.applyErr }
36+
func (m *mockK8s) ApplyPVC(_ context.Context, _ string, _ []byte) error { return m.applyErr }

0 commit comments

Comments
 (0)