diff --git a/.drone.yml b/.drone.yml index 8cc93fc..82112ab 100644 --- a/.drone.yml +++ b/.drone.yml @@ -34,9 +34,9 @@ steps: path: /shared commands: - export PATH=/drone/src/pkg.tools:$PATH - - go install github.com/a-h/templ/cmd/templ@latest - - go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest - - go install github.com/swaggo/swag/cmd/swag@latest + - go install github.com/a-h/templ/cmd/templ@v0.3.977 + - go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 + - go install github.com/swaggo/swag/cmd/swag@v1.16.6 # - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest - sqlc generate - templ generate -path ./components diff --git a/README.md b/README.md index ac603c6..871efc0 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,40 @@ Hourly and daily snapshot table retention can be configured in the settings file - `settings.hourly_snapshot_max_age_days` (default: 60) - `settings.daily_snapshot_max_age_months` (default: 12) +## Runtime Environment Flags +These flags are read from the process environment (for example via `/etc/default/vctp` on systemd installs): + +- `VCTP_ENCRYPTION_KEY`: optional explicit key source for credential encryption/decryption. + Recommended for stable behavior across host migrations/rebuilds. +- `VCTP_ENABLE_EXPERIMENTAL_POSTGRES`: set to `1` to enable experimental PostgreSQL driver startup. +- `VCTP_ENABLE_LEGACY_API`: set to `1` to re-enable deprecated legacy API endpoints temporarily. + +## Credential Encryption Lifecycle +At startup, vCTP resolves `settings.vcenter_password` using this order: + +1. If value starts with `enc:v1:`, decrypt using the active key. +2. If no prefix, attempt legacy ciphertext decryption (active key, then legacy fallback keys). +3. If decrypt fails and value length is greater than 2, treat value as plaintext. + +When steps 2 or 3 succeed, vCTP rewrites the setting in-place to `enc:v1:`. + +Behavior notes: +- Plaintext values with length `<= 2` are rejected. +- Malformed ciphertext is rejected safely (short payloads do not panic). +- Legacy encrypted values can still be migrated forward automatically. + +## Deprecated API Endpoints +These endpoints are considered legacy and are disabled by default unless `VCTP_ENABLE_LEGACY_API=1`: + +- `/api/event/vm/create` +- `/api/event/vm/modify` +- `/api/event/vm/move` +- `/api/event/vm/delete` +- `/api/cleanup/updates` +- `/api/cleanup/vcenter` + +When disabled, they return HTTP `410 Gone` with JSON error payload. + ## Settings Reference All configuration lives under the top-level `settings:` key in `vctp.yml`. @@ -94,7 +128,7 @@ General: - `settings.log_output`: log format, `text` or `json` Database: -- `settings.database_driver`: `sqlite` or `postgres` +- `settings.database_driver`: `sqlite` or `postgres` (experimental; requires `VCTP_ENABLE_EXPERIMENTAL_POSTGRES=1`) - `settings.database_url`: SQLite file path/DSN or PostgreSQL DSN HTTP/TLS: @@ -138,9 +172,9 @@ Filters/chargeback: ## Pre-requisite tools ```shell -go install github.com/a-h/templ/cmd/templ@latest -go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest -go install github.com/swaggo/swag/cmd/swag@latest +go install github.com/a-h/templ/cmd/templ@v0.3.977 +go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 +go install github.com/swaggo/swag/cmd/swag@v1.16.6 ``` ## Database @@ -163,6 +197,19 @@ Run `templ generate -path ./components` to generate code based on template files ## Documentation Run `swag init --exclude "pkg.mod,pkg.build,pkg.tools" -o server/router/docs` +## Tests +Run the test suite: + +```shell +go test ./... +``` + +Recommended static analysis: + +```shell +go vet ./... +``` + ## CI/CD (Drone) - `.drone.yml` defines a Docker pipeline: - Restore/build caches for Go modules/tools. diff --git a/server/handler/dailyCreationDiagnostics.go b/server/handler/dailyCreationDiagnostics.go index 2fef1dd..d15fbd9 100644 --- a/server/handler/dailyCreationDiagnostics.go +++ b/server/handler/dailyCreationDiagnostics.go @@ -3,7 +3,6 @@ package handler import ( "context" "database/sql" - "encoding/json" "fmt" "net/http" "strings" @@ -186,20 +185,18 @@ LIMIT %d } response := models.DailyCreationDiagnosticsResponse{ - Status: "OK", - Date: parsed.Format("2006-01-02"), - Table: tableName, - TotalRows: totalRows, - MissingCreationCount: missingTotal, - MissingCreationPct: missingPct, - AvgIsPresentLtOneCount: avgIsPresentLtOne, - MissingCreationPartialCount: missingPartialCount, - MissingByVcenter: byVcenter, - Samples: samples, + Status: "OK", + Date: parsed.Format("2006-01-02"), + Table: tableName, + TotalRows: totalRows, + MissingCreationCount: missingTotal, + MissingCreationPct: missingPct, + AvgIsPresentLtOneCount: avgIsPresentLtOne, + MissingCreationPartialCount: missingPartialCount, + MissingByVcenter: byVcenter, + Samples: samples, MissingCreationPartialSamples: partialSamples, } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) + writeJSON(w, http.StatusOK, response) } diff --git a/server/handler/encryptData.go b/server/handler/encryptData.go index 7b9b613..3368f74 100644 --- a/server/handler/encryptData.go +++ b/server/handler/encryptData.go @@ -57,9 +57,7 @@ func (h *Handler) EncryptData(w http.ResponseWriter, r *http.Request) { } h.Logger.Debug("encrypted plaintext payload", "input_length", len(plaintext)) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{ + writeJSON(w, http.StatusOK, map[string]string{ "status": "OK", "message": cipherText, "prefixed": encryptedValuePrefixV1 + cipherText, diff --git a/server/handler/encryptData_test.go b/server/handler/encryptData_test.go new file mode 100644 index 0000000..19fc4cd --- /dev/null +++ b/server/handler/encryptData_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "vctp/internal/secrets" +) + +func newEncryptTestHandler() (*Handler, *secrets.Secrets) { + logger := newTestLogger() + key := []byte("0123456789abcdef0123456789abcdef") + secret := secrets.New(logger, key) + return &Handler{ + Logger: logger, + Secret: secret, + }, secret +} + +func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder) map[string]string { + t.Helper() + var resp map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response body %q: %v", rr.Body.String(), err) + } + return resp +} + +func TestEncryptDataRejectsWrongMethod(t *testing.T) { + h, _ := newEncryptTestHandler() + req := httptest.NewRequest(http.MethodGet, "/api/encrypt", nil) + rr := httptest.NewRecorder() + + h.EncryptData(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, rr.Code) + } + resp := decodeResponse(t, rr) + if resp["status"] != "ERROR" { + t.Fatalf("expected status ERROR, got %#v", resp) + } +} + +func TestEncryptDataRejectsInvalidJSON(t *testing.T) { + h, _ := newEncryptTestHandler() + req := httptest.NewRequest(http.MethodPost, "/api/encrypt", strings.NewReader("{")) + rr := httptest.NewRecorder() + + h.EncryptData(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + resp := decodeResponse(t, rr) + if resp["status"] != "ERROR" { + t.Fatalf("expected status ERROR, got %#v", resp) + } +} + +func TestEncryptDataAcceptsPlaintextField(t *testing.T) { + h, secret := newEncryptTestHandler() + req := httptest.NewRequest(http.MethodPost, "/api/encrypt", strings.NewReader(`{"plaintext":"super-secret"}`)) + rr := httptest.NewRecorder() + + h.EncryptData(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + resp := decodeResponse(t, rr) + if resp["status"] != "OK" { + t.Fatalf("expected status OK, got %#v", resp) + } + if resp["ciphertext"] == "" || resp["prefixed"] == "" { + t.Fatalf("expected ciphertext+prefixed fields, got %#v", resp) + } + if !strings.HasPrefix(resp["prefixed"], encryptedValuePrefixV1) { + t.Fatalf("expected prefixed value with %q, got %q", encryptedValuePrefixV1, resp["prefixed"]) + } + if !strings.EqualFold(resp["message"], resp["ciphertext"]) { + t.Fatalf("expected message to mirror ciphertext, got %#v", resp) + } + plain, err := secret.Decrypt(resp["ciphertext"]) + if err != nil { + t.Fatalf("unable to decrypt ciphertext response: %v", err) + } + if string(plain) != "super-secret" { + t.Fatalf("unexpected decrypted value %q", string(plain)) + } +} + +func TestEncryptDataAcceptsLegacyValueField(t *testing.T) { + h, secret := newEncryptTestHandler() + body := bytes.NewBufferString(`{"value":"legacy-input"}`) + req := httptest.NewRequest(http.MethodPost, "/api/encrypt", body) + rr := httptest.NewRecorder() + + h.EncryptData(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + resp := decodeResponse(t, rr) + cipherText := resp["ciphertext"] + if cipherText == "" { + t.Fatalf("expected ciphertext in response, got %#v", resp) + } + plain, err := secret.Decrypt(cipherText) + if err != nil { + t.Fatalf("unable to decrypt ciphertext response: %v", err) + } + if string(plain) != "legacy-input" { + t.Fatalf("unexpected decrypted value %q", string(plain)) + } +} + +func TestEncryptDataRejectsMissingPayloadValue(t *testing.T) { + h, _ := newEncryptTestHandler() + req := httptest.NewRequest(http.MethodPost, "/api/encrypt", strings.NewReader(`{}`)) + rr := httptest.NewRecorder() + + h.EncryptData(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + resp := decodeResponse(t, rr) + if resp["status"] != "ERROR" { + t.Fatalf("expected status ERROR, got %#v", resp) + } +} diff --git a/server/handler/legacy_gate_test.go b/server/handler/legacy_gate_test.go new file mode 100644 index 0000000..cd34493 --- /dev/null +++ b/server/handler/legacy_gate_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDenyLegacyAPIDisabledByDefault(t *testing.T) { + t.Setenv(legacyAPIEnvVar, "") + h := &Handler{Logger: newTestLogger()} + rr := httptest.NewRecorder() + + denied := h.denyLegacyAPI(rr, "/api/event/vm/create") + if !denied { + t.Fatal("expected legacy API to be denied by default") + } + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + if !strings.Contains(rr.Body.String(), "deprecated") { + t.Fatalf("unexpected response body: %s", rr.Body.String()) + } +} + +func TestDenyLegacyAPIEnabledViaEnv(t *testing.T) { + t.Setenv(legacyAPIEnvVar, "1") + h := &Handler{Logger: newTestLogger()} + rr := httptest.NewRecorder() + + denied := h.denyLegacyAPI(rr, "/api/event/vm/create") + if denied { + t.Fatal("expected legacy API to be allowed when env var is set") + } + if rr.Body.Len() != 0 { + t.Fatalf("expected no response body write, got: %s", rr.Body.String()) + } +} + +func TestVmCreateEventHonorsLegacyGate(t *testing.T) { + h := &Handler{Logger: newTestLogger()} + + t.Run("disabled", func(t *testing.T) { + t.Setenv(legacyAPIEnvVar, "") + req := httptest.NewRequest(http.MethodPost, "/api/event/vm/create", strings.NewReader("{invalid")) + rr := httptest.NewRecorder() + h.VmCreateEvent(rr, req) + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + }) + + t.Run("enabled", func(t *testing.T) { + t.Setenv(legacyAPIEnvVar, "1") + req := httptest.NewRequest(http.MethodPost, "/api/event/vm/create", strings.NewReader("{invalid")) + rr := httptest.NewRecorder() + h.VmCreateEvent(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d when gate is open, got %d", http.StatusBadRequest, rr.Code) + } + }) +} diff --git a/server/handler/reportDownload.go b/server/handler/reportDownload.go index 1fe7eb8..da62a2c 100644 --- a/server/handler/reportDownload.go +++ b/server/handler/reportDownload.go @@ -2,7 +2,6 @@ package handler import ( "context" - "encoding/json" "fmt" "net/http" "vctp/internal/report" @@ -24,12 +23,7 @@ func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request reportData, err := report.CreateInventoryReport(h.Logger, h.Database, ctx) if err != nil { h.Logger.Error("Failed to create report", "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to create xlsx report: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to create xlsx report: '%s'", err)) return } @@ -58,12 +52,7 @@ func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) { reportData, err := report.CreateUpdatesReport(h.Logger, h.Database, ctx) if err != nil { h.Logger.Error("Failed to create report", "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to create xlsx report: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to create xlsx report: '%s'", err)) return } diff --git a/server/handler/response.go b/server/handler/response.go new file mode 100644 index 0000000..1251e46 --- /dev/null +++ b/server/handler/response.go @@ -0,0 +1,41 @@ +package handler + +import ( + "encoding/json" + "net/http" + "vctp/server/models" +) + +func writeJSON(w http.ResponseWriter, statusCode int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeJSONStatus(w http.ResponseWriter, statusCode int, status string) { + writeJSON(w, statusCode, models.StatusResponse{ + Status: status, + }) +} + +func writeJSONStatusMessage(w http.ResponseWriter, statusCode int, status, message string) { + writeJSON(w, statusCode, models.StatusMessageResponse{ + Status: status, + Message: message, + }) +} + +func writeJSONOK(w http.ResponseWriter) { + writeJSONStatus(w, http.StatusOK, "OK") +} + +func writeJSONOKMessage(w http.ResponseWriter, message string) { + writeJSONStatusMessage(w, http.StatusOK, "OK", message) +} + +func writeJSONError(w http.ResponseWriter, statusCode int, message string) { + writeJSON(w, statusCode, models.ErrorResponse{ + Status: "ERROR", + Message: message, + }) +} diff --git a/server/handler/snapshotAggregate.go b/server/handler/snapshotAggregate.go index 0fa3691..a4480bb 100644 --- a/server/handler/snapshotAggregate.go +++ b/server/handler/snapshotAggregate.go @@ -2,13 +2,11 @@ package handler import ( "context" - "encoding/json" "net/http" "strings" "time" "vctp/internal/settings" "vctp/internal/tasks" - "vctp/server/models" ) // SnapshotAggregateForce forces regeneration of a daily or monthly summary table. @@ -102,18 +100,5 @@ func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request) "granularity", granularity, "duration", time.Since(startedAt), ) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - }) -} - -func writeJSONError(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(models.ErrorResponse{ - Status: "ERROR", - Message: message, - }) + writeJSONOK(w) } diff --git a/server/handler/snapshotForceHourly.go b/server/handler/snapshotForceHourly.go index 62daec4..a643b28 100644 --- a/server/handler/snapshotForceHourly.go +++ b/server/handler/snapshotForceHourly.go @@ -2,7 +2,6 @@ package handler import ( "context" - "encoding/json" "net/http" "strings" "time" @@ -44,8 +43,5 @@ func (h *Handler) SnapshotForceHourly(w http.ResponseWriter, r *http.Request) { } h.Logger.Info("Manual hourly snapshot completed", "duration", time.Since(started)) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - }) + writeJSONOK(w) } diff --git a/server/handler/snapshotMigrate.go b/server/handler/snapshotMigrate.go index d5a514f..ae95a33 100644 --- a/server/handler/snapshotMigrate.go +++ b/server/handler/snapshotMigrate.go @@ -2,9 +2,9 @@ package handler import ( "context" - "encoding/json" "net/http" "vctp/internal/report" + "vctp/server/models" ) // SnapshotMigrate rebuilds the snapshot registry and normalizes hourly table names. @@ -19,20 +19,28 @@ func (h *Handler) SnapshotMigrate(w http.ResponseWriter, r *http.Request) { ctx := context.Background() stats, err := report.MigrateSnapshotRegistry(ctx, h.Database) if err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ERROR", - "error": err.Error(), - "stats": stats, + writeJSON(w, http.StatusInternalServerError, models.SnapshotMigrationResponse{ + Status: "ERROR", + Error: err.Error(), + Stats: models.SnapshotMigrationStats{ + HourlyRenamed: stats.HourlyRenamed, + HourlyRegistered: stats.HourlyRegistered, + DailyRegistered: stats.DailyRegistered, + MonthlyRegistered: stats.MonthlyRegistered, + Errors: stats.Errors, + }, }) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "OK", - "stats": stats, + writeJSON(w, http.StatusOK, models.SnapshotMigrationResponse{ + Status: "OK", + Stats: models.SnapshotMigrationStats{ + HourlyRenamed: stats.HourlyRenamed, + HourlyRegistered: stats.HourlyRegistered, + DailyRegistered: stats.DailyRegistered, + MonthlyRegistered: stats.MonthlyRegistered, + Errors: stats.Errors, + }, }) } diff --git a/server/handler/snapshotRegenerateHourly.go b/server/handler/snapshotRegenerateHourly.go index c6d6ecb..6245c52 100644 --- a/server/handler/snapshotRegenerateHourly.go +++ b/server/handler/snapshotRegenerateHourly.go @@ -1,13 +1,13 @@ package handler import ( - "encoding/json" "net/http" "os" "path/filepath" "strings" "time" "vctp/internal/report" + "vctp/server/models" ) // SnapshotRegenerateHourlyReports regenerates missing hourly snapshot XLSX reports on disk. @@ -54,15 +54,14 @@ func (h *Handler) SnapshotRegenerateHourlyReports(w http.ResponseWriter, r *http regenerated++ } - resp := map[string]interface{}{ - "status": "OK", - "total": len(records), - "regenerated": regenerated, - "skipped": skipped, - "errors": errors, - "reports_dir": reportsDir, - "snapshotType": "hourly", + resp := models.SnapshotRegenerateReportsResponse{ + Status: "OK", + Total: len(records), + Regenerated: regenerated, + Skipped: skipped, + Errors: errors, + ReportsDir: reportsDir, + SnapshotType: "hourly", } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + writeJSON(w, http.StatusOK, resp) } diff --git a/server/handler/snapshotRepair.go b/server/handler/snapshotRepair.go index ca597ed..3a863eb 100644 --- a/server/handler/snapshotRepair.go +++ b/server/handler/snapshotRepair.go @@ -2,7 +2,6 @@ package handler import ( "context" - "encoding/json" "fmt" "net/http" "strconv" @@ -10,6 +9,7 @@ import ( "time" "vctp/db" "vctp/internal/report" + "vctp/server/models" ) // SnapshotRepair scans existing daily summaries and backfills missing SnapshotTime and lifecycle fields. @@ -21,20 +21,18 @@ import ( // @Router /api/snapshots/repair [post] func (h *Handler) SnapshotRepair(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") return } h.Logger.Info("snapshot repair started", "scope", "daily") repaired, failed := h.repairDailySummaries(r.Context(), time.Now()) h.Logger.Info("snapshot repair finished", "daily_repaired", repaired, "daily_failed", failed) - resp := map[string]string{ - "status": "ok", - "repaired": strconv.Itoa(repaired), - "failed": strconv.Itoa(failed), - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + writeJSON(w, http.StatusOK, models.SnapshotRepairResponse{ + Status: "OK", + Repaired: strconv.Itoa(repaired), + Failed: strconv.Itoa(failed), + }) } func (h *Handler) repairDailySummaries(ctx context.Context, now time.Time) (repaired int, failed int) { @@ -42,8 +40,8 @@ func (h *Handler) repairDailySummaries(ctx context.Context, now time.Time) (repa dailyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "daily", "inventory_daily_summary_", "20060102", time.Time{}, now) if err != nil { - h.Logger.Warn("failed to list daily summaries", "error", err) - return 0, 1 + h.Logger.Warn("failed to list daily summaries", "error", err) + return 0, 1 } for _, rec := range dailyRecs { @@ -53,27 +51,27 @@ func (h *Handler) repairDailySummaries(ctx context.Context, now time.Time) (repa if err := db.EnsureSummaryTable(ctx, dbConn, rec.TableName); err != nil { h.Logger.Warn("ensure summary table failed", "table", rec.TableName, "error", err) - failed++ - continue - } + failed++ + continue + } - hourlyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd) - if err != nil || len(hourlyRecs) == 0 { - h.Logger.Warn("no hourly snapshots for repair window", "table", rec.TableName, "error", err) - failed++ - continue - } + hourlyRecs, err := report.SnapshotRecordsWithFallback(ctx, h.Database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd) + if err != nil || len(hourlyRecs) == 0 { + h.Logger.Warn("no hourly snapshots for repair window", "table", rec.TableName, "error", err) + failed++ + continue + } - cols := []string{ - `"InventoryId"`, `"Name"`, `"Vcenter"`, `"VmId"`, `"EventKey"`, `"CloudId"`, `"CreationTime"`, - `"DeletionTime"`, `"ResourcePool"`, `"Datacenter"`, `"Cluster"`, `"Folder"`, - `"ProvisionedDisk"`, `"VcpuCount"`, `"RamGB"`, `"IsTemplate"`, `"PoweredOn"`, - `"SrmPlaceholder"`, `"VmUuid"`, `"SnapshotTime"`, - } - union, err := buildUnionFromRecords(hourlyRecs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`) - if err != nil { - h.Logger.Warn("failed to build union for repair", "table", rec.TableName, "error", err) - failed++ + cols := []string{ + `"InventoryId"`, `"Name"`, `"Vcenter"`, `"VmId"`, `"EventKey"`, `"CloudId"`, `"CreationTime"`, + `"DeletionTime"`, `"ResourcePool"`, `"Datacenter"`, `"Cluster"`, `"Folder"`, + `"ProvisionedDisk"`, `"VcpuCount"`, `"RamGB"`, `"IsTemplate"`, `"PoweredOn"`, + `"SrmPlaceholder"`, `"VmUuid"`, `"SnapshotTime"`, + } + union, err := buildUnionFromRecords(hourlyRecs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`) + if err != nil { + h.Logger.Warn("failed to build union for repair", "table", rec.TableName, "error", err) + failed++ continue } @@ -106,7 +104,7 @@ func (h *Handler) repairDailySummaries(ctx context.Context, now time.Time) (repa // @Router /api/snapshots/repair/all [post] func (h *Handler) SnapshotRepairSuite(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") return } @@ -135,15 +133,13 @@ func (h *Handler) SnapshotRepairSuite(w http.ResponseWriter, r *http.Request) { h.Logger.Info("repair suite step", "step", "monthly_refine") monthlyRefined, monthlyFailed := h.refineMonthlyFromDaily(ctx, time.Now()) - resp := map[string]string{ - "status": "ok", - "daily_repaired": strconv.Itoa(dailyRepaired), - "daily_failed": strconv.Itoa(dailyFailed), - "monthly_refined": strconv.Itoa(monthlyRefined), - "monthly_failed": strconv.Itoa(monthlyFailed), - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + writeJSON(w, http.StatusOK, models.SnapshotRepairSuiteResponse{ + Status: "OK", + DailyRepaired: strconv.Itoa(dailyRepaired), + DailyFailed: strconv.Itoa(dailyFailed), + MonthlyRefined: strconv.Itoa(monthlyRefined), + MonthlyFailed: strconv.Itoa(monthlyFailed), + }) } func (h *Handler) refineMonthlyFromDaily(ctx context.Context, now time.Time) (refined int, failed int) { @@ -178,11 +174,11 @@ func (h *Handler) refineMonthlyFromDaily(ctx context.Context, now time.Time) (re continue } - union, err := buildUnionFromRecords(recs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`) - if err != nil { - h.Logger.Warn("failed to build union for monthly refine", "table", summaryTable, "error", err) - failed++ - continue + union, err := buildUnionFromRecords(recs, cols, `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE','true','1')`) + if err != nil { + h.Logger.Warn("failed to build union for monthly refine", "table", summaryTable, "error", err) + failed++ + continue } if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, union); err != nil { diff --git a/server/handler/snapshots.go b/server/handler/snapshots.go index 11bc821..7324288 100644 --- a/server/handler/snapshots.go +++ b/server/handler/snapshots.go @@ -2,7 +2,6 @@ package handler import ( "context" - "encoding/json" "fmt" "net/http" "net/url" @@ -62,24 +61,14 @@ func (h *Handler) SnapshotReportDownload(w http.ResponseWriter, r *http.Request) ctx := context.Background() tableName := r.URL.Query().Get("table") if tableName == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": "Missing table parameter", - }) + writeJSONError(w, http.StatusBadRequest, "Missing table parameter") return } reportData, err := report.CreateTableReport(h.Logger, h.Database, ctx, tableName) if err != nil { h.Logger.Error("Failed to create snapshot report", "error", err, "table", tableName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to create snapshot report: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to create snapshot report: '%s'", err)) return } diff --git a/server/handler/test_helpers_test.go b/server/handler/test_helpers_test.go new file mode 100644 index 0000000..1dbeabf --- /dev/null +++ b/server/handler/test_helpers_test.go @@ -0,0 +1,10 @@ +package handler + +import ( + "io" + "log/slog" +) + +func newTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/server/handler/updateCleanup.go b/server/handler/updateCleanup.go index d709c23..b4f9d3b 100644 --- a/server/handler/updateCleanup.go +++ b/server/handler/updateCleanup.go @@ -43,12 +43,9 @@ func (h *Handler) UpdateCleanup(w http.ResponseWriter, r *http.Request) { if err != nil { h.Logger.Error("Error received cleaning updates table", "error", err) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Delete Request unsuccessful %s\n", err) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Delete Request unsuccessful %s", err)) } else { h.Logger.Debug("Processed update cleanup successfully") - w.WriteHeader(http.StatusOK) - // TODO - return some JSON - fmt.Fprintf(w, "Processed update cleanup successfully") + writeJSONOKMessage(w, "Processed update cleanup successfully") } } diff --git a/server/handler/vcCleanup.go b/server/handler/vcCleanup.go index ef849f0..5095253 100644 --- a/server/handler/vcCleanup.go +++ b/server/handler/vcCleanup.go @@ -3,7 +3,6 @@ package handler import ( "context" "database/sql" - "encoding/json" "errors" "fmt" "net/http" @@ -35,21 +34,11 @@ func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) { if err != nil { if errors.Is(err, sql.ErrNoRows) { h.Logger.Error("No VMs found for vcenter", "url", vcUrl) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("No match to vcenter details specified. vc_url: '%s'", vcUrl), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("No match to vcenter details specified. vc_url: '%s'", vcUrl)) return } else { h.Logger.Error("Error checking for vcenter to cleanup", "error", err, "url", vcUrl) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Error checking for vcenter to cleanup. error: '%s'", err), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Error checking for vcenter to cleanup. error: '%s'", err)) return } } else { @@ -57,33 +46,18 @@ func (h *Handler) VcCleanup(w http.ResponseWriter, r *http.Request) { err = h.Database.Queries().InventoryCleanupVcenter(ctx, vcUrl) if err != nil { h.Logger.Error("Error cleaning up VMs from Inventory table", "error", err, "url", vcUrl) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Error cleaning up VMs from Inventory table. error: '%s'", err), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Error cleaning up VMs from Inventory table. error: '%s'", err)) return } else { // Successful cleanup h.Logger.Debug("VMs successfully removed from inventory for vcenter", "url", vcUrl) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": fmt.Sprintf("Removed VMs from Inventory table for vcenter '%s'", vcUrl), - }) + writeJSONOKMessage(w, fmt.Sprintf("Removed VMs from Inventory table for vcenter '%s'", vcUrl)) return } } } else { h.Logger.Error("Parameters not correctly specified", "url", vcUrl) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Parameters not correctly specified. vc_url: '%s'", vcUrl), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Parameters not correctly specified. vc_url: '%s'", vcUrl)) return } } diff --git a/server/handler/vmCleanup.go b/server/handler/vmCleanup.go index c5d6ecc..f445336 100644 --- a/server/handler/vmCleanup.go +++ b/server/handler/vmCleanup.go @@ -3,7 +3,6 @@ package handler import ( "context" "database/sql" - "encoding/json" "errors" "fmt" "net/http" @@ -38,21 +37,11 @@ func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) { if err != nil { if errors.Is(err, sql.ErrNoRows) { h.Logger.Error("No VM found matching parameters", "vm_id", vmId, "datacenter_name", datacenterName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("No match to VM details specified. vm_id: '%s', datacenter_name: '%s'", vmId, datacenterName), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("No match to VM details specified. vm_id: '%s', datacenter_name: '%s'", vmId, datacenterName)) return } else { h.Logger.Error("Error checking for VM to cleanup", "error", err, "vm_id", vmId, "datacenter_name", datacenterName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Error checking for VM to cleanup. error: '%s'", err), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Error checking for VM to cleanup. error: '%s'", err)) return } } else { @@ -68,33 +57,18 @@ func (h *Handler) VmCleanup(w http.ResponseWriter, r *http.Request) { err = h.Database.Queries().InventoryCleanup(ctx, params) if err != nil { h.Logger.Error("Error cleaning up VM from Inventory table", "error", err, "vm_id", vmId, "datacenter_name", datacenterName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Error cleaning up VM from Inventory table. error: '%s'", err), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Error cleaning up VM from Inventory table. error: '%s'", err)) return } else { // Successful cleanup h.Logger.Debug("VM successfully removed from inventory", "vm_name", vm.Name, "iid", vm.Iid, "vm_id", vmId, "datacenter_name", datacenterName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": fmt.Sprintf("VM '%s' removed from Inventory table", vm.Name), - }) + writeJSONOKMessage(w, fmt.Sprintf("VM '%s' removed from Inventory table", vm.Name)) return } } } else { h.Logger.Error("Parameters not correctly specified", "vm_id", vmId, "datacenter_name", datacenterName) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Parameters not correctly specified. vm_id: '%s', datacenter_name: '%s'", vmId, datacenterName), - }) + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Parameters not correctly specified. vm_id: '%s', datacenter_name: '%s'", vmId, datacenterName)) return } } diff --git a/server/handler/vmCreateEvent.go b/server/handler/vmCreateEvent.go index f0b7fcc..218666b 100644 --- a/server/handler/vmCreateEvent.go +++ b/server/handler/vmCreateEvent.go @@ -20,11 +20,11 @@ import ( // @Tags events // @Deprecated // @Accept json -// @Produce text/plain +// @Produce json // @Param event body models.CloudEventReceived true "CloudEvent payload" -// @Success 200 {string} string "Create event processed" -// @Failure 400 {string} string "Invalid request" -// @Failure 500 {string} string "Server error" +// @Success 200 {object} models.StatusMessageResponse "Create event processed" +// @Failure 400 {object} models.ErrorResponse "Invalid request" +// @Failure 500 {object} models.ErrorResponse "Server error" // @Router /api/event/vm/create [post] func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) { if h.denyLegacyAPI(w, "/api/event/vm/create") { @@ -41,8 +41,7 @@ func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) { reqBody, err := io.ReadAll(r.Body) if err != nil { h.Logger.Error("Invalid data received", "error", err) - fmt.Fprintf(w, "Invalid data received") - w.WriteHeader(http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "Invalid data received") return } else { h.Logger.Debug("received input data", "length", len(reqBody)) @@ -52,7 +51,7 @@ func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) { var event models.CloudEventReceived if err := json.Unmarshal(reqBody, &event); err != nil { h.Logger.Error("unable to decode json", "error", err) - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "Invalid JSON body") return } else { h.Logger.Debug("successfully decoded JSON") @@ -100,16 +99,13 @@ func (h *Handler) VmCreateEvent(w http.ResponseWriter, r *http.Request) { result, err := h.Database.Queries().CreateEvent(context.Background(), params) if err != nil { h.Logger.Error("unable to perform database insert", "error", err) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Error : %v\n", err) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Error: %v", err)) return } else { h.Logger.Debug("created database record", "insert_result", result) } - //h.Logger.Debug("received create request", "body", string(reqBody)) - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Create Request : %v\n", result) + writeJSONOKMessage(w, fmt.Sprintf("Create request processed: %v", result)) } // prettyPrint comes from https://gist.github.com/sfate/9d45f6c5405dc4c9bf63bf95fe6d1a7c diff --git a/server/handler/vmDeleteEvent.go b/server/handler/vmDeleteEvent.go index a72b58e..78affbf 100644 --- a/server/handler/vmDeleteEvent.go +++ b/server/handler/vmDeleteEvent.go @@ -18,11 +18,11 @@ import ( // @Tags events // @Deprecated // @Accept json -// @Produce text/plain +// @Produce json // @Param event body models.CloudEventReceived true "CloudEvent payload" -// @Success 200 {string} string "Delete event processed" -// @Failure 400 {string} string "Invalid request" -// @Failure 500 {string} string "Server error" +// @Success 200 {object} models.StatusMessageResponse "Delete event processed" +// @Failure 400 {object} models.ErrorResponse "Invalid request" +// @Failure 500 {object} models.ErrorResponse "Server error" // @Router /api/event/vm/delete [post] func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) { if h.denyLegacyAPI(w, "/api/event/vm/delete") { @@ -36,8 +36,7 @@ func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) { reqBody, err := io.ReadAll(r.Body) if err != nil { h.Logger.Error("Invalid data received", "error", err) - fmt.Fprintf(w, "Invalid data received") - w.WriteHeader(http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "Invalid data received") return } else { //h.Logger.Debug("received input data", "length", len(reqBody)) @@ -48,7 +47,7 @@ func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) { if err := json.Unmarshal(reqBody, &event); err != nil { h.Logger.Error("unable to decode json", "error", err) prettyPrint(event) - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "Invalid JSON body") return } else { h.Logger.Debug("successfully decoded deletion type cloud event", "vm_id", event.CloudEvent.Data.VM.VM.Value) @@ -76,13 +75,10 @@ func (h *Handler) VmDeleteEvent(w http.ResponseWriter, r *http.Request) { if err != nil { h.Logger.Error("Error received marking VM as deleted", "error", err) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Delete Request unsuccessful %s\n", err) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Delete Request unsuccessful %s", err)) } else { h.Logger.Debug("Processed VM Deletion event successfully") - w.WriteHeader(http.StatusOK) - // TODO - return some JSON - fmt.Fprintf(w, "Processed VM Deletion event successfully") + writeJSONOKMessage(w, "Processed VM Deletion event successfully") } //h.Logger.Debug("received delete request", "body", string(reqBody)) diff --git a/server/handler/vmImport.go b/server/handler/vmImport.go index b454add..242f3f3 100644 --- a/server/handler/vmImport.go +++ b/server/handler/vmImport.go @@ -29,12 +29,7 @@ func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) { reqBody, err := io.ReadAll(r.Body) if err != nil { h.Logger.Error("Invalid data received", "length", len(reqBody), "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Invalid data received: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid data received: '%s'", err)) return } else { @@ -45,12 +40,7 @@ func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) { var inData models.ImportReceived if err := json.Unmarshal(reqBody, &inData); err != nil { h.Logger.Error("Unable to decode json request body", "length", len(reqBody), "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to decode json request body: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to decode json request body: '%s'", err)) return } else { //h.Logger.Debug("successfully decoded JSON") @@ -59,12 +49,7 @@ func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(inData.Name, "vCLS-") { h.Logger.Info("Skipping internal vCLS VM import", "vm_name", inData.Name) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": fmt.Sprintf("Skipped internal VM '%s'", inData.Name), - }) + writeJSONOKMessage(w, fmt.Sprintf("Skipped internal VM '%s'", inData.Name)) return } @@ -103,10 +88,5 @@ func (h *Handler) VmImport(w http.ResponseWriter, r *http.Request) { h.Logger.Info("not adding vm to inventory table since record alraedy exists", "vm_id", inData.VmId, "datacenter_name", inData.Datacenter) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": fmt.Sprintf("Successfully processed import request for VM '%s'", inData.Name), - }) + writeJSONOKMessage(w, fmt.Sprintf("Successfully processed import request for VM '%s'", inData.Name)) } diff --git a/server/handler/vmModifyEvent.go b/server/handler/vmModifyEvent.go index 57d3736..d1594ea 100644 --- a/server/handler/vmModifyEvent.go +++ b/server/handler/vmModifyEvent.go @@ -46,12 +46,7 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { reqBody, err := io.ReadAll(r.Body) if err != nil { h.Logger.Error("Invalid data received", "length", len(reqBody), "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Invalid data received: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid data received: '%s'", err)) return } @@ -59,12 +54,7 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { var event models.CloudEventReceived if err := json.Unmarshal(reqBody, &event); err != nil { h.Logger.Error("Unable to decode json request body", "length", len(reqBody), "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to decode json request body: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to decode json request body: '%s'", err)) return } else { //h.Logger.Debug("successfully decoded JSON") @@ -74,12 +64,7 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { if event.CloudEvent.Data.ConfigChanges == nil { h.Logger.Warn("Received event contains no config change") prettyPrint(event) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": "Received update event successfully but no config changes were found", - }) + writeJSONStatusMessage(w, http.StatusAccepted, "OK", "Received update event successfully but no config changes were found") return } else { h.Logger.Info("Received event contains config change info", "source", event.CloudEvent.Source, @@ -239,13 +224,8 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { iid, err2 := h.AddVmToInventory(event, ctx, unixTimestamp) if err2 != nil { h.Logger.Error("Received error adding VM to inventory", "error", err2) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Valid request but experienced error adding vm id '%s' in datacenter name '%s' to inventory table : %s", - event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err2), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Valid request but experienced error adding vm id '%s' in datacenter name '%s' to inventory table : %s", + event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err2)) return } @@ -253,24 +233,14 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { params.InventoryId = sql.NullInt64{Int64: iid, Valid: iid > 0} } else { h.Logger.Error("Received zero for inventory id when adding VM to inventory") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Valid request but received zero result when adding vm id '%s' in datacenter name '%s' to inventory table", - event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Valid request but received zero result when adding vm id '%s' in datacenter name '%s' to inventory table", + event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name)) return } } else { h.Logger.Error("unable to find existing inventory record for this VM", "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Valid request but could not locate vm id '%s' and datacenter name '%s' within inventory table : %s", - event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Valid request but could not locate vm id '%s' and datacenter name '%s' within inventory table : %s", + event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err)) return } @@ -282,11 +252,7 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { if params.UpdateType == "diskChange" && invResult.ProvisionedDisk.Float64 == params.NewProvisionedDisk.Float64 { h.Logger.Info("VM update type was for disk size but current size of VM matches inventory record, no need for update record", "vm_name", invResult.Name, "db_value", invResult.ProvisionedDisk.Float64, "new_value", params.NewProvisionedDisk.Float64) - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": "Successfully processed vm modify event", - }) + writeJSONOKMessage(w, "Successfully processed vm modify event") return } @@ -303,23 +269,17 @@ func (h *Handler) VmModifyEvent(w http.ResponseWriter, r *http.Request) { result, err := h.Database.Queries().CreateUpdate(ctx, params) if err != nil { h.Logger.Error("unable to perform database insert", "error", err) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Error : %v\n", err) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Error : %v", err)) return } else { h.Logger.Debug("created database record", "insert_result", result) - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": "Successfully processed vm modify event", - }) + writeJSONOKMessage(w, "Successfully processed vm modify event") return } } else { h.Logger.Debug("Didn't find any configuration changes of interest", "id", event.CloudEvent.ID, "vm", event.CloudEvent.Data.VM.Name, "config_changes", configChanges) - w.WriteHeader(http.StatusAccepted) - fmt.Fprintf(w, "Processed update event but no config changes were of interest\n") + writeJSONStatusMessage(w, http.StatusAccepted, "OK", "Processed update event but no config changes were of interest") //prettyPrint(event.CloudEvent.Data.ConfigSpec) } } diff --git a/server/handler/vmMoveEvent.go b/server/handler/vmMoveEvent.go index 3174b57..ce40b21 100644 --- a/server/handler/vmMoveEvent.go +++ b/server/handler/vmMoveEvent.go @@ -39,8 +39,7 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { reqBody, err := io.ReadAll(r.Body) if err != nil { h.Logger.Error("Invalid data received", "error", err) - fmt.Fprintf(w, "Invalid data received") - w.WriteHeader(http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "Invalid data received") return } else { //h.Logger.Debug("received input data", "length", len(reqBody)) @@ -51,12 +50,7 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { if err := json.Unmarshal(reqBody, &event); err != nil { h.Logger.Error("unable to unmarshal json", "error", err) prettyPrint(reqBody) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to unmarshal JSON in request body: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to unmarshal JSON in request body: '%s'", err)) return } else { h.Logger.Debug("successfully decoded JSON") @@ -66,12 +60,7 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { if event.CloudEvent.Data.OldParent == nil || event.CloudEvent.Data.NewParent == nil { h.Logger.Error("No resource pool data found in cloud event") prettyPrint(event) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": "CloudEvent missing resource pool data", - }) + writeJSONError(w, http.StatusBadRequest, "CloudEvent missing resource pool data") return } @@ -92,13 +81,8 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { iid, err2 := h.AddVmToInventory(event, ctx, unixTimestamp) if err2 != nil { h.Logger.Error("Received error adding VM to inventory", "error", err2) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Valid request but experienced error adding vm id '%s' in datacenter name '%s' to inventory table : %s", - event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err2), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Valid request but experienced error adding vm id '%s' in datacenter name '%s' to inventory table : %s", + event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name, err2)) return } @@ -106,13 +90,8 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { params.InventoryId = sql.NullInt64{Int64: iid, Valid: iid > 0} } else { h.Logger.Error("Received zero for inventory id when adding VM to inventory") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Valid request but received zero result when adding vm id '%s' in datacenter name '%s' to inventory table", - event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Valid request but received zero result when adding vm id '%s' in datacenter name '%s' to inventory table", + event.CloudEvent.Data.VM.VM.Value, event.CloudEvent.Data.Datacenter.Name)) return } } @@ -142,22 +121,12 @@ func (h *Handler) VmMoveEvent(w http.ResponseWriter, r *http.Request) { result, err := h.Database.Queries().CreateUpdate(ctx, params) if err != nil { h.Logger.Error("unable to perform database insert", "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "status": "ERROR", - "message": fmt.Sprintf("Unable to insert move event into database: '%s'", err), - }) + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Unable to insert move event into database: '%s'", err)) return } else { h.Logger.Debug("created database record", "insert_result", result) - w.WriteHeader(http.StatusOK) - //fmt.Fprintf(w, "Processed update event: %v\n", result) - json.NewEncoder(w).Encode(map[string]string{ - "status": "OK", - "message": "Successfully processed move event", - }) + writeJSONOKMessage(w, "Successfully processed move event") return } } diff --git a/server/handler/vmUpdateDetails.go b/server/handler/vmUpdateDetails.go index 70032eb..4c3643a 100644 --- a/server/handler/vmUpdateDetails.go +++ b/server/handler/vmUpdateDetails.go @@ -3,7 +3,6 @@ package handler import ( "context" "database/sql" - "fmt" "net/http" "vctp/db/queries" "vctp/internal/vcenter" @@ -13,9 +12,9 @@ import ( // @Summary Refresh VM details // @Description Queries vCenter and updates inventory records with missing details. // @Tags inventory -// @Produce text/plain -// @Success 200 {string} string "Update completed" -// @Failure 500 {string} string "Server error" +// @Produce json +// @Success 200 {object} models.StatusMessageResponse "Update completed" +// @Failure 500 {object} models.ErrorResponse "Server error" // @Router /api/inventory/vm/update [post] func (h *Handler) VmUpdateDetails(w http.ResponseWriter, r *http.Request) { var matchFound bool @@ -42,8 +41,8 @@ func (h *Handler) VmUpdateDetails(w http.ResponseWriter, r *http.Request) { results, err := h.Database.Queries().GetInventoryByVcenter(ctx, url) if err != nil { h.Logger.Error("Unable to query inventory table", "error", err) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Unable to query inventory table %s\n", err) + writeJSONError(w, http.StatusInternalServerError, "Unable to query inventory table") + return } if len(results) == 0 { @@ -115,7 +114,5 @@ func (h *Handler) VmUpdateDetails(w http.ResponseWriter, r *http.Request) { } h.Logger.Debug("Processed vm update successfully") - w.WriteHeader(http.StatusOK) - // TODO - return some JSON - fmt.Fprintf(w, "Processed vm update successfully") + writeJSONOKMessage(w, "Processed vm update successfully") }