All checks were successful
Check / check (pull_request) Successful in 1m45s
- Add Prometheus metrics package (internal/metrics) with deployment, container health, webhook, HTTP request, and audit counters/histograms - Add audit_log SQLite table via migration 007 - Add AuditEntry model with CRUD operations and query methods - Add audit service (internal/service/audit) for recording user actions - Instrument deploy service with deployment duration, count, and in-flight metrics; container health gauge updates on deploy completion - Instrument webhook service with event counters by app/type/matched - Instrument HTTP middleware with request count, duration, and response size metrics; also log response bytes in structured request logs - Add audit logging to all key handler operations: login/logout, app CRUD, deploy, cancel, rollback, restart/stop/start, webhook receipt, and initial setup - Add GET /api/audit endpoint for querying recent audit entries - Make /metrics endpoint always available (optionally auth-protected) - Add comprehensive tests for metrics, audit model, and audit service - Update existing test infrastructure with metrics and audit dependencies - Update README with Observability section documenting all metrics, audit log, and structured logging
326 lines
8.7 KiB
Go
326 lines
8.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/upaas/internal/models"
|
|
)
|
|
|
|
// apiAppResponse is the JSON representation of an app.
|
|
type apiAppResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Branch string `json:"branch"`
|
|
DockerfilePath string `json:"dockerfilePath"`
|
|
Status string `json:"status"`
|
|
WebhookSecret string `json:"webhookSecret"`
|
|
SSHPublicKey string `json:"sshPublicKey"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// apiDeploymentResponse is the JSON representation of a deployment.
|
|
type apiDeploymentResponse struct {
|
|
ID int64 `json:"id"`
|
|
AppID string `json:"appId"`
|
|
CommitSHA string `json:"commitSha,omitempty"`
|
|
Status string `json:"status"`
|
|
Duration string `json:"duration,omitempty"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt string `json:"finishedAt,omitempty"`
|
|
}
|
|
|
|
func appToAPI(a *models.App) apiAppResponse {
|
|
return apiAppResponse{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
RepoURL: a.RepoURL,
|
|
Branch: a.Branch,
|
|
DockerfilePath: a.DockerfilePath,
|
|
Status: string(a.Status),
|
|
WebhookSecret: a.WebhookSecret,
|
|
SSHPublicKey: a.SSHPublicKey,
|
|
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|
resp := apiDeploymentResponse{
|
|
ID: d.ID,
|
|
AppID: d.AppID,
|
|
Status: string(d.Status),
|
|
Duration: d.Duration(),
|
|
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if d.CommitSHA.Valid {
|
|
resp.CommitSHA = d.CommitSHA.String
|
|
}
|
|
|
|
if d.FinishedAt.Valid {
|
|
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
|
// and sets a session cookie.
|
|
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|
type loginResponse struct {
|
|
UserID int64 `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
var req map[string]string
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
|
if decodeErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid JSON body"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
username := req["username"]
|
|
credential := req["password"]
|
|
|
|
if username == "" || credential == "" {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "username and password are required"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
|
if authErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid credentials"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
sessionErr := h.auth.CreateSession(writer, request, user)
|
|
if sessionErr != nil {
|
|
h.log.Error("api: failed to create session", "error", sessionErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to create session"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.auditLog(request, models.AuditActionLogin,
|
|
models.AuditResourceSession, "", "api login")
|
|
|
|
h.respondJSON(writer, request, loginResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIListApps returns a handler that lists all apps as JSON.
|
|
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
apps, err := h.appService.ListApps(request.Context())
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list apps"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiAppResponse, 0, len(apps))
|
|
for _, a := range apps {
|
|
result = append(result, appToAPI(a))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIGetApp returns a handler that gets a single app by ID.
|
|
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "internal server error"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// deploymentsPageLimit is the default number of deployments per page.
|
|
const deploymentsPageLimit = 20
|
|
|
|
// HandleAPIListDeployments returns a handler that lists deployments for an app.
|
|
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil || application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
limit := deploymentsPageLimit
|
|
|
|
if l := request.URL.Query().Get("limit"); l != "" {
|
|
parsed, parseErr := strconv.Atoi(l)
|
|
if parseErr == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
deployments, deployErr := application.GetDeployments(
|
|
request.Context(), limit,
|
|
)
|
|
if deployErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list deployments"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiDeploymentResponse, 0, len(deployments))
|
|
for _, d := range deployments {
|
|
result = append(result, deploymentToAPI(d))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
|
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
|
type whoAmIResponse struct {
|
|
UserID int64 `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
|
if err != nil || user == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "unauthorized"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, whoAmIResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// auditLogDefaultLimit is the default number of audit entries returned.
|
|
const auditLogDefaultLimit = 50
|
|
|
|
// auditLogMaxLimit is the maximum number of audit entries returned.
|
|
const auditLogMaxLimit = 500
|
|
|
|
// HandleAPIAuditLog returns a handler that lists recent audit log entries.
|
|
func (h *Handlers) HandleAPIAuditLog() http.HandlerFunc {
|
|
type auditEntryResponse struct {
|
|
ID int64 `json:"id"`
|
|
UserID *int64 `json:"userId,omitempty"`
|
|
Username string `json:"username"`
|
|
Action string `json:"action"`
|
|
ResourceType string `json:"resourceType"`
|
|
ResourceID string `json:"resourceId,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
RemoteIP string `json:"remoteIp,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
limit := auditLogDefaultLimit
|
|
|
|
if limitStr := request.URL.Query().Get("limit"); limitStr != "" {
|
|
parsed, parseErr := strconv.Atoi(limitStr)
|
|
if parseErr == nil && parsed > 0 && parsed <= auditLogMaxLimit {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
entries, err := h.audit.Recent(request.Context(), limit)
|
|
if err != nil {
|
|
h.log.Error("failed to fetch audit log", "error", err)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to fetch audit log"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]auditEntryResponse, 0, len(entries))
|
|
|
|
for _, e := range entries {
|
|
entry := auditEntryResponse{
|
|
ID: e.ID,
|
|
Username: e.Username,
|
|
Action: string(e.Action),
|
|
ResourceType: string(e.ResourceType),
|
|
CreatedAt: e.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if e.UserID.Valid {
|
|
id := e.UserID.Int64
|
|
entry.UserID = &id
|
|
}
|
|
|
|
entry.ResourceID = nullStringValue(e.ResourceID)
|
|
entry.Detail = nullStringValue(e.Detail)
|
|
entry.RemoteIP = nullStringValue(e.RemoteIP)
|
|
|
|
result = append(result, entry)
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// nullStringValue returns the string value if valid, empty string otherwise.
|
|
func nullStringValue(ns sql.NullString) string {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
|
|
return ""
|
|
}
|