Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/scan/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PORT=8080
SCAN_DB_PATH=./data/scan.db
70 changes: 70 additions & 0 deletions services/scan/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

"securecloud/scan/internal/handler"
"securecloud/scan/internal/store"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
port := getEnv("PORT", "8080")
dbPath := getEnv("SCAN_DB_PATH", "./data/scan.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
logger.Error("failed to create database directory", "error", err)
os.Exit(1)
}
scanStore, err := store.New(dbPath)
if err != nil {
logger.Error("failed to initialise database", "error", err)
os.Exit(1)
}
defer scanStore.Close()
server := &http.Server{
Addr: ":" + port,
Handler: handler.New(scanStore, logger).Router(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErrors := make(chan error, 1)
go func() {
logger.Info("scan service starting", "port", port, "database", dbPath)
serverErrors <- server.ListenAndServe()
}()
signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case <-signalContext.Done():
logger.Info("shutdown signal received")
case err := <-serverErrors:
if !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP server failed", "error", err)
return
}
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
logger.Error("graceful shutdown failed", "error", err)
return
}
logger.Info("scan service stopped")
}

func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
20 changes: 20 additions & 0 deletions services/scan/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module securecloud/scan

go 1.26.4

require (
github.com/go-chi/chi/v5 v5.3.1
modernc.org/sqlite v1.56.0
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
52 changes: 52 additions & 0 deletions services/scan/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
132 changes: 132 additions & 0 deletions services/scan/internal/handler/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package handler

import (
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"

"securecloud/scan/internal/store"
)

type Handler struct {
store *store.Store
logger *slog.Logger
}

func New(scanStore *store.Store, logger *slog.Logger) *Handler {
return &Handler{store: scanStore, logger: logger}
}

func (h *Handler) Router() http.Handler {
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.Recoverer)
router.Use(h.requestLogger)
router.Get("/healthz", h.healthz)
router.Get("/readyz", h.readyz)
router.Post("/scan", h.createScan)
router.Get("/scan/{id}", h.getScan)
router.Get("/scans", h.listScans)
return router
}

func (h *Handler) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (h *Handler) readyz(w http.ResponseWriter, r *http.Request) {
if err := h.store.Ping(r.Context()); err != nil {
h.logger.Error("database readiness check failed", "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

type createScanRequest struct {
Target string `json:"target"`
}

func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) {
var request createScanRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON request")
Comment on lines +57 to +62
return
}
request.Target = strings.TrimSpace(request.Target)
Comment on lines +61 to +65
if request.Target == "" {
writeError(w, http.StatusBadRequest, "target is required")
return
}
scan, err := h.store.CreateScan(r.Context(), request.Target)
if err != nil {
h.logger.Error("failed to create scan", "error", err)
writeError(w, http.StatusInternalServerError, "failed to create scan")
return
}
h.logger.Info("scan created", "scan_id", scan.ID, "target", scan.Target)
writeJSON(w, http.StatusAccepted, scan)
}

func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
scan, err := h.store.GetScan(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "scan not found")
return
}
if err != nil {
h.logger.Error("failed to retrieve scan", "scan_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to retrieve scan")
return
}
writeJSON(w, http.StatusOK, scan)
}

func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) {
scans, err := h.store.ListScans(r.Context())
if err != nil {
h.logger.Error("failed to list scans", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list scans")
return
}
writeJSON(w, http.StatusOK, scans)
}

func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

type statusRecorder struct {
http.ResponseWriter
status int
}

func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}

func (h *Handler) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
h.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "status", recorder.status, "duration_ms", time.Since(start).Milliseconds())
})
}
Comment on lines +115 to +132
101 changes: 101 additions & 0 deletions services/scan/internal/handler/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package handler

import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

"securecloud/scan/internal/store"
)

func newTestRouter(t *testing.T) http.Handler {
t.Helper()
scanStore, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
t.Cleanup(func() { _ = scanStore.Close() })
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
return New(scanStore, logger).Router()
}

func performRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

func TestHealthEndpoints(t *testing.T) {
router := newTestRouter(t)
for _, test := range []struct {
path string
body string
}{
{path: "/healthz", body: `{"status":"ok"}`},
{path: "/readyz", body: `{"status":"ready"}`},
} {
response := performRequest(router, http.MethodGet, test.path, "")
if response.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", test.path, response.Code)
}
if strings.TrimSpace(response.Body.String()) != test.body {
t.Errorf("GET %s body = %q, want %q", test.path, response.Body.String(), test.body)
}
}
}

func TestScanLifecycle(t *testing.T) {
router := newTestRouter(t)
createdResponse := performRequest(router, http.MethodPost, "/scan", `{"target":" example.com "}`)
if createdResponse.Code != http.StatusAccepted {
t.Fatalf("POST /scan status = %d, want 202", createdResponse.Code)
}
var created store.Scan
if err := json.NewDecoder(createdResponse.Body).Decode(&created); err != nil {
t.Fatalf("decode POST response: %v", err)
}
if created.Target != "example.com" || created.Status != store.StatusQueued {
t.Fatalf("POST /scan = %+v", created)
}
getResponse := performRequest(router, http.MethodGet, "/scan/"+created.ID, "")
if getResponse.Code != http.StatusOK {
t.Fatalf("GET /scan/{id} status = %d, want 200", getResponse.Code)
}
listResponse := performRequest(router, http.MethodGet, "/scans", "")
if listResponse.Code != http.StatusOK {
t.Fatalf("GET /scans status = %d, want 200", listResponse.Code)
}
var scans []store.Scan
if err := json.NewDecoder(listResponse.Body).Decode(&scans); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(scans) != 1 || scans[0].ID != created.ID {
t.Fatalf("GET /scans = %+v", scans)
}
}

func TestCreateScanRejectsInvalidRequests(t *testing.T) {
router := newTestRouter(t)
for _, body := range []string{`{"target":""}`, `{"target":" "}`, `hello`, `{"target":"example.com","extra":true}`} {
response := performRequest(router, http.MethodPost, "/scan", body)
if response.Code != http.StatusBadRequest {
t.Errorf("POST /scan body %q status = %d, want 400", body, response.Code)
}
}
}

func TestGetScanNotFound(t *testing.T) {
response := performRequest(newTestRouter(t), http.MethodGet, "/scan/does-not-exist", "")
if response.Code != http.StatusNotFound {
t.Fatalf("GET missing scan status = %d, want 404", response.Code)
}
}
Loading