diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 84291ec..fe4eccf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -14,6 +14,18 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Unformatted files:" + echo "$unformatted" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + - name: Run tests with coverage run: | go test -v -coverprofile=coverage.out -coverpkg=./... ./... diff --git a/cmd/diunwebhook/main.go b/cmd/diunwebhook/main.go index 06d9b7d..161d3a0 100644 --- a/cmd/diunwebhook/main.go +++ b/cmd/diunwebhook/main.go @@ -1,17 +1,54 @@ package main import ( + "context" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" diun "awesomeProject/pkg/diunwebhook" ) func main() { - http.HandleFunc("/webhook", diun.WebhookHandler) - http.HandleFunc("/api/updates", diun.UpdatesHandler) - http.Handle("/", http.FileServer(http.Dir("./static"))) + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } - log.Println("Listening on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + mux := http.NewServeMux() + mux.HandleFunc("/webhook", diun.WebhookHandler) + mux.HandleFunc("/api/updates", diun.UpdatesHandler) + mux.Handle("/", http.FileServer(http.Dir("./static"))) + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("Listening on :%s", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("ListenAndServe: %v", err) + } + }() + + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Printf("Shutdown error: %v", err) + } else { + log.Println("Server stopped cleanly") + } } diff --git a/pkg/diunwebhook/diunwebhook.go b/pkg/diunwebhook/diunwebhook.go index eb6b5e4..398e3ce 100644 --- a/pkg/diunwebhook/diunwebhook.go +++ b/pkg/diunwebhook/diunwebhook.go @@ -32,17 +32,6 @@ var ( updates = make(map[string]DiunEvent) ) -// Exported for test package -func GetUpdatesMap() map[string]DiunEvent { - return updates -} - -func UpdatesReset() { - mu.Lock() - defer mu.Unlock() - updates = make(map[string]DiunEvent) -} - func UpdateEvent(event DiunEvent) { mu.Lock() defer mu.Unlock() @@ -60,10 +49,21 @@ func GetUpdates() map[string]DiunEvent { } func WebhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var event DiunEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + log.Printf("WebhookHandler: failed to decode request: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if event.Image == "" { + http.Error(w, "bad request: image field is required", http.StatusBadRequest) return } diff --git a/test/diunwebhook/main_test.go b/pkg/diunwebhook/diunwebhook_test.go similarity index 73% rename from test/diunwebhook/main_test.go rename to pkg/diunwebhook/diunwebhook_test.go index 1eb679e..827829a 100644 --- a/test/diunwebhook/main_test.go +++ b/pkg/diunwebhook/diunwebhook_test.go @@ -4,8 +4,10 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "sync" "testing" "time" @@ -104,6 +106,58 @@ func TestUpdatesHandler_EncodeError(t *testing.T) { // No panic = pass } +func TestWebhookHandler_MethodNotAllowed(t *testing.T) { + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete} + for _, method := range methods { + req := httptest.NewRequest(method, "/webhook", nil) + rec := httptest.NewRecorder() + diun.WebhookHandler(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("method %s: expected 405, got %d", method, rec.Code) + } + } + // POST should not return 405 + event := diun.DiunEvent{Image: "nginx:latest"} + body, _ := json.Marshal(event) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + diun.WebhookHandler(rec, req) + if rec.Code == http.StatusMethodNotAllowed { + t.Errorf("POST should not return 405") + } +} + +func TestWebhookHandler_EmptyImage(t *testing.T) { + diun.UpdatesReset() + body, _ := json.Marshal(diun.DiunEvent{Image: ""}) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + diun.WebhookHandler(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for empty image, got %d", rec.Code) + } + if len(diun.GetUpdatesMap()) != 0 { + t.Errorf("expected map to stay empty, got %d entries", len(diun.GetUpdatesMap())) + } +} + +func TestConcurrentUpdateEvent(t *testing.T) { + diun.UpdatesReset() + const n = 100 + var wg sync.WaitGroup + wg.Add(n) + for i := range n { + go func(i int) { + defer wg.Done() + diun.UpdateEvent(diun.DiunEvent{Image: fmt.Sprintf("image:%d", i)}) + }(i) + } + wg.Wait() + if got := len(diun.GetUpdatesMap()); got != n { + t.Errorf("expected %d entries, got %d", n, got) + } +} + func TestMainHandlerIntegration(t *testing.T) { diun.UpdatesReset() // reset global state // Start test server diff --git a/pkg/diunwebhook/export_test.go b/pkg/diunwebhook/export_test.go new file mode 100644 index 0000000..e1975e6 --- /dev/null +++ b/pkg/diunwebhook/export_test.go @@ -0,0 +1,12 @@ +package diunwebhook + +func GetUpdatesMap() map[string]DiunEvent { + mu.Lock() + defer mu.Unlock() + return updates +} +func UpdatesReset() { + mu.Lock() + defer mu.Unlock() + updates = make(map[string]DiunEvent) +} diff --git a/static/index.html b/static/index.html index 4f21f83..4e0f942 100644 --- a/static/index.html +++ b/static/index.html @@ -15,9 +15,11 @@ Image - Tag Status - Last Seen + Hostname + Provider + Digest + Created @@ -28,16 +30,24 @@ const res = await fetch('/api/updates'); const data = await res.json(); const tbody = document.querySelector('tbody'); - tbody.innerHTML = ''; + tbody.textContent = ''; Object.values(data).forEach(update => { - const row = ` - ${update.image} - ${update.tag} - ${update.status} - ${update.time} - `; - tbody.innerHTML += row; + const row = document.createElement('tr'); + const fields = [ + update.image, + update.status, + update.hostname, + update.provider, + update.digest, + update.created ? new Date(update.created).toLocaleString() : '', + ]; + fields.forEach(value => { + const td = document.createElement('td'); + td.textContent = value || ''; + row.appendChild(td); + }); + tbody.appendChild(row); }); } @@ -45,4 +55,4 @@ load(); - \ No newline at end of file + diff --git a/test/diunwebhook/.keep b/test/diunwebhook/.keep deleted file mode 100644 index e69de29..0000000