From 27eed687f6fe9d14b06b691b9323dbf87a0d3f49 Mon Sep 17 00:00:00 2001 From: Indra Gunawan <1284811+coderbuzz@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:07:59 +0700 Subject: [PATCH] feat: robust stop/start + idempotent init + mock banner + DNS safety --- cmd/dockify/main.go | 2 +- internal/app/service.go | 117 ++++++++++++-------- internal/http/router.go | 14 ++- internal/http/templates.go | 12 ++ internal/http/templates/layout.html | 32 +++++- internal/http/templates/servers_detail.html | 2 + internal/server/handler.go | 2 + internal/server/service.go | 18 ++- 8 files changed, 143 insertions(+), 56 deletions(-) diff --git a/cmd/dockify/main.go b/cmd/dockify/main.go index f03cae7..9de46a3 100644 --- a/cmd/dockify/main.go +++ b/cmd/dockify/main.go @@ -77,7 +77,7 @@ func main() { serverListAdapter := &serverLister{svc: svc} webhookSecretFn := func() string { s, _ := settingsSvc.GetWebhookSecret(); return s } - router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version) + router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock) if cfg.AdminPass == "" { log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.") diff --git a/internal/app/service.go b/internal/app/service.go index b81a0b6..f91782b 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -190,38 +190,7 @@ func (s *Service) deployWithCommit(id int64, commitSHA string) { } if app.Domain != "" { - target := fmt.Sprintf("%s:%d", getServiceName(composeContent), app.Port) - caddyClient := caddy.NewClient(client) - var caddyErr error - if app.AuthUser != "" && app.AuthPass != "" { - caddyErr = caddyClient.AddRouteWithAuth(app.Domain, target, app.AuthUser, app.AuthPass) - log.Printf("Caddy route (with auth) added: %s -> %s", app.Domain, target) - } else { - caddyErr = caddyClient.AddRoute(app.Domain, target) - log.Printf("Caddy route added: %s -> %s", app.Domain, target) - } - if caddyErr != nil { - log.Printf("Warning: caddy route injection failed for %q: %v", app.Name, caddyErr) - logs = append(logs, fmt.Sprintf("caddy: %v", caddyErr)) - } - s.repo.SaveRoute(&Route{ - AppID: id, - ServerID: svr.ID, - Domain: app.Domain, - Target: target, - }) - - if s.cf != nil && s.cf.Enabled() { - s.repo.DeleteDNSRecords(id) - record, err := s.cf.UpsertRecord(app.Domain, svr.Host, false) - if err != nil { - log.Printf("Warning: Cloudflare DNS failed for %q: %v", app.Name, err) - logs = append(logs, fmt.Sprintf("dns: %v", err)) - } else { - log.Printf("DNS record upserted: %s -> %s", record.Name, record.Content) - s.repo.SaveDNSRecord(id, svr.ID, record.ZoneID, record.ID, record.Name, "A", record.Content, record.Proxied) - } - } + s.setupRouteAndDNS(app, svr, client, composeContent, &logs) } else { log.Printf("App %q deployed as internal service (no domain, no Caddy route)", app.Name) } @@ -288,22 +257,9 @@ func (s *Service) Undeploy(id int64) error { return fmt.Errorf("app not found") } - if s.cf != nil && s.cf.Enabled() { - dnsRecords, err := s.repo.GetDNSRecords(id) - if err == nil { - for _, rec := range dnsRecords { - if delErr := s.cf.DeleteRecord(rec.RecordID); delErr != nil { - log.Printf("Warning: failed to delete DNS record %s: %v", rec.RecordID, delErr) - } - s.repo.DeleteDNSRecord(rec.ID) - } - } - } - svr, err := s.serverRepo.Get(app.ServerID) if err != nil || svr == nil { log.Printf("Undeploy %q: server not found, cleaning up DB", app.Name) - s.repo.DeleteDNSRecords(app.ID) s.repo.DeleteRoutes(app.ID) s.repo.DeleteDeployments(app.ID) s.repo.Delete(id) @@ -313,7 +269,6 @@ func (s *Service) Undeploy(id int64) error { client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { log.Printf("Undeploy %q: SSH connect failed, cleaning up DB: %v", app.Name, err) - s.repo.DeleteDNSRecords(app.ID) s.repo.DeleteRoutes(app.ID) s.repo.DeleteDeployments(app.ID) s.repo.Delete(id) @@ -336,7 +291,6 @@ func (s *Service) Undeploy(id int64) error { } s.repo.DeleteRoutes(app.ID) - s.repo.DeleteDNSRecords(app.ID) s.repo.DeleteDeployments(app.ID) client.Exec(fmt.Sprintf("rm -rf %s", remoteDir)) @@ -346,6 +300,59 @@ func (s *Service) Undeploy(id int64) error { return nil } +func (s *Service) setupRouteAndDNS(app *App, svr *server.Server, client ssh.Connector, composeContent string, logs *[]string) { + target := fmt.Sprintf("%s:%d", getServiceName(composeContent), app.Port) + caddyClient := caddy.NewClient(client) + var caddyErr error + if app.AuthUser != "" && app.AuthPass != "" { + caddyErr = caddyClient.AddRouteWithAuth(app.Domain, target, app.AuthUser, app.AuthPass) + log.Printf("Caddy route (with auth) added: %s -> %s", app.Domain, target) + } else { + caddyErr = caddyClient.AddRoute(app.Domain, target) + log.Printf("Caddy route added: %s -> %s", app.Domain, target) + } + if caddyErr != nil { + log.Printf("Warning: caddy route injection failed for %q: %v", app.Name, caddyErr) + if logs != nil { + *logs = append(*logs, fmt.Sprintf("caddy: %v", caddyErr)) + } + } + s.repo.SaveRoute(&Route{ + AppID: app.ID, + ServerID: svr.ID, + Domain: app.Domain, + Target: target, + }) + + if s.cf != nil && s.cf.Enabled() { + records, err := s.cf.ListRecords(app.Domain) + if err != nil { + log.Printf("Warning: Cloudflare DNS lookup failed for %q: %v", app.Name, err) + return + } + exists := false + for _, r := range records { + if r.Name == app.Domain && r.Type == "A" { + exists = true + log.Printf("DNS A record already exists for %s, skipping (IP: %s, proxied: %v)", app.Domain, r.Content, r.Proxied) + break + } + } + if !exists { + record, err := s.cf.CreateRecord(app.Domain, svr.Host, false) + if err != nil { + log.Printf("Warning: Cloudflare DNS failed for %q: %v", app.Name, err) + if logs != nil { + *logs = append(*logs, fmt.Sprintf("dns: %v", err)) + } + } else { + log.Printf("DNS A record created: %s -> %s", record.Name, record.Content) + s.repo.SaveDNSRecord(app.ID, svr.ID, record.ZoneID, record.ID, record.Name, "A", record.Content, record.Proxied) + } + } + } +} + func (s *Service) Redeploy(id int64) { app, err := s.repo.Get(id) if err != nil || app == nil { @@ -376,6 +383,13 @@ func (s *Service) Stop(id int64) error { composePath := fmt.Sprintf("/opt/dockify/apps/%s/docker-compose.yml", app.Name) log.Printf("Stopping %q on %s...", app.Name, svr.Name) + if app.Domain != "" { + caddyClient := caddy.NewClient(client) + if err := caddyClient.RemoveRoute(app.Domain); err != nil { + log.Printf("Warning: failed to remove Caddy route for %q: %v", app.Name, err) + } + } + if out, err := client.Exec(fmt.Sprintf("%s -f %s stop 2>&1", dc, composePath)); err != nil { return fmt.Errorf("compose stop: %w\n%s", err, out) } @@ -410,6 +424,15 @@ func (s *Service) Start(id int64) error { return fmt.Errorf("compose start: %w\n%s", err, out) } + if app.Domain != "" { + composeRemote, err := client.Exec(fmt.Sprintf("cat %s", composePath)) + if err == nil { + s.setupRouteAndDNS(app, svr, client, composeRemote, nil) + } else { + log.Printf("Warning: could not read remote compose for %q: %v", app.Name, err) + } + } + s.repo.UpdateStatus(id, StatusRunning) log.Printf("App %q started", app.Name) return nil diff --git a/internal/http/router.go b/internal/http/router.go index dbf6240..8cf8d2f 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -14,7 +14,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux { +func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string, devMock bool) *chi.Mux { r := chi.NewRouter() r.Use(chimw.Logger) @@ -22,6 +22,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv r.Use(chimw.RealIP) r.Use(PrefixMiddleware) r.Use(CORSMiddleware) + r.Use(DevMockMiddleware(devMock)) authEnabled := cfgPass != "" @@ -240,3 +241,14 @@ func CORSMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func DevMockMiddleware(devMock bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if devMock { + r = SetDevMock(r, true) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/templates.go b/internal/http/templates.go index 4afb77d..0bde8b8 100644 --- a/internal/http/templates.go +++ b/internal/http/templates.go @@ -68,6 +68,7 @@ var tmpl = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "te type contextKey string const basePathKey contextKey = "basePath" +const devMockKey contextKey = "devMock" func SetBasePath(r *http.Request, path string) *http.Request { ctx := context.WithValue(r.Context(), basePathKey, path) @@ -81,6 +82,16 @@ func GetBasePath(r *http.Request) string { return "/" } +func SetDevMock(r *http.Request, val bool) *http.Request { + ctx := context.WithValue(r.Context(), devMockKey, val) + return r.WithContext(ctx) +} + +func GetDevMock(r *http.Request) bool { + v, _ := r.Context().Value(devMockKey).(bool) + return v +} + type RenderFunc func(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) func Render(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) { @@ -94,6 +105,7 @@ func Render(w http.ResponseWriter, r *http.Request, status int, name string, dat m, ok := data.(map[string]interface{}) if ok { m["BasePath"] = basePath + m["DevMock"] = GetDevMock(r) } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index 9d09d06..689865f 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -260,6 +260,35 @@ .empty-state { text-align: center; padding: 3em 1em; color: var(--text-muted); } .empty-state p { margin-bottom: 0.8em; } +@keyframes blink-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.dev-mock-bar { + background: var(--yellow); + color: #000; + text-align: center; + padding: 0.35em 1em; + font-size: 0.82em; + font-weight: 600; + animation: blink-pulse 1.5s ease-in-out infinite; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.dev-mock-badge { + display: inline-block; + background: var(--yellow); + color: #000; + font-size: 0.72em; + font-weight: 700; + padding: 0.15em 0.5em; + border-radius: 3px; + animation: blink-pulse 1.5s ease-in-out infinite; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-left: 0.5em; +} + @media (max-width: 600px) { .container { padding: 0.8em 1em; } .grid { grid-template-columns: 1fr; } @@ -268,8 +297,9 @@ {{end}} {{define "nav"}} +{{if .DevMock}}
{{end}}