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