Refactor project structure: modularized code into pkg and cmd directories, added unit tests, improved CI/CD pipeline, and enhanced documentation.
This commit is contained in:
28
.gitea/workflows/ci.yml
Normal file
28
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
go test -v -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out | tee coverage.txt
|
||||||
|
cov=$(go tool cover -func=coverage.out | grep total: | awk '{print substr($3, 1, length($3)-1)}')
|
||||||
|
cov=${cov%.*}
|
||||||
|
if [ "$cov" -lt 80 ]; then
|
||||||
|
echo "::warning::Test coverage is below 80% ($cov%)"
|
||||||
|
fi
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t diun-webhook-dashboard .
|
||||||
|
|
||||||
14
Dockerfile
14
Dockerfile
@@ -1,11 +1,15 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY go.mod .
|
||||||
RUN go build -o app
|
|
||||||
|
|
||||||
FROM alpine:latest
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o server ./cmd/diunwebhook/main.go
|
||||||
|
|
||||||
|
FROM alpine:3.18
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/app .
|
COPY --from=builder /app/server ./server
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["./app"]
|
CMD ["./server"]
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -55,6 +55,12 @@ Expected JSON payload (simplified):
|
|||||||
|
|
||||||
Note: data is only kept in-memory and will be reset on restart.
|
Note: data is only kept in-memory and will be reset on restart.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `cmd/diunwebhook/` — main application source and tests
|
||||||
|
- `static/` — static assets
|
||||||
|
- `Dockerfile`, `docker-compose.yml`, `go.mod`, `go.sum` — project config/build files
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
- Code: `main.go`
|
- Code: `main.go`
|
||||||
- Static assets: `static/`
|
- Static assets: `static/`
|
||||||
@@ -65,5 +71,19 @@ Note: data is only kept in-memory and will be reset on restart.
|
|||||||
- Persistence is not implemented; hook up a store (e.g., BoltDB/Redis/Postgres) if you need durability.
|
- Persistence is not implemented; hook up a store (e.g., BoltDB/Redis/Postgres) if you need durability.
|
||||||
- Consider adding auth, rate limiting, or a secret/token on the webhook endpoint if exposed publicly.
|
- Consider adding auth, rate limiting, or a secret/token on the webhook endpoint if exposed publicly.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run unit tests and check coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v -cover
|
||||||
|
```
|
||||||
|
|
||||||
|
Aim for 80–90% coverage. Coverage below 80% will emit a warning in CI but will not fail the pipeline.
|
||||||
|
|
||||||
|
## CI/CD with Gitea Actions
|
||||||
|
|
||||||
|
A sample Gitea Actions workflow is provided in `.gitea/workflows/ci.yml` to automate build, test, and coverage checks.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
MIT — see `LICENSE`.
|
MIT — see `LICENSE`.
|
||||||
|
|||||||
0
cmd/diunwebhook/.keep
Normal file
0
cmd/diunwebhook/.keep
Normal file
17
cmd/diunwebhook/main.go
Normal file
17
cmd/diunwebhook/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
diun "awesomeProject/pkg/diunwebhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/webhook", diun.WebhookHandler)
|
||||||
|
http.HandleFunc("/api/updates", diun.UpdatesHandler)
|
||||||
|
http.Handle("/", http.FileServer(http.Dir("./static")))
|
||||||
|
|
||||||
|
log.Println("Listening on :8080")
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package diunwebhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -32,7 +32,34 @@ var (
|
|||||||
updates = make(map[string]DiunEvent)
|
updates = make(map[string]DiunEvent)
|
||||||
)
|
)
|
||||||
|
|
||||||
func webhookHandler(w http.ResponseWriter, r *http.Request) {
|
// 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()
|
||||||
|
updates[event.Image] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUpdates() map[string]DiunEvent {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
updatesCopy := make(map[string]DiunEvent, len(updates))
|
||||||
|
for k, v := range updates {
|
||||||
|
updatesCopy[k] = v
|
||||||
|
}
|
||||||
|
return updatesCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var event DiunEvent
|
var event DiunEvent
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||||
@@ -40,29 +67,16 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
UpdateEvent(event)
|
||||||
// Use image as key (or container name if preferred)
|
|
||||||
updates[event.Image] = event
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("Update received: %s (%s)", event.Image, event.Status)
|
log.Printf("Update received: %s (%s)", event.Image, event.Status)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatesHandler(w http.ResponseWriter, r *http.Request) {
|
func UpdatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(updates)
|
if err := json.NewEncoder(w).Encode(GetUpdates()); err != nil {
|
||||||
}
|
log.Printf("failed to encode updates: %v", err)
|
||||||
|
}
|
||||||
func main() {
|
|
||||||
http.HandleFunc("/webhook", webhookHandler)
|
|
||||||
http.HandleFunc("/api/updates", updatesHandler)
|
|
||||||
http.Handle("/", http.FileServer(http.Dir("./static")))
|
|
||||||
|
|
||||||
log.Println("Listening on :8080")
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
||||||
}
|
}
|
||||||
0
test/diunwebhook/.keep
Normal file
0
test/diunwebhook/.keep
Normal file
152
test/diunwebhook/main_test.go
Normal file
152
test/diunwebhook/main_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package diunwebhook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
diun "awesomeProject/pkg/diunwebhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateEventAndGetUpdates(t *testing.T) {
|
||||||
|
diun.UpdatesReset() // helper to reset global state
|
||||||
|
event := diun.DiunEvent{
|
||||||
|
DiunVersion: "1.0",
|
||||||
|
Hostname: "host",
|
||||||
|
Status: "new",
|
||||||
|
Provider: "docker",
|
||||||
|
Image: "nginx:latest",
|
||||||
|
HubLink: "https://hub.docker.com/nginx",
|
||||||
|
MimeType: "application/json",
|
||||||
|
Digest: "sha256:abc",
|
||||||
|
Created: time.Now(),
|
||||||
|
Platform: "linux/amd64",
|
||||||
|
}
|
||||||
|
diun.UpdateEvent(event)
|
||||||
|
got := diun.GetUpdates()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 update, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got["nginx:latest"].DiunVersion != "1.0" {
|
||||||
|
t.Errorf("unexpected DiunVersion: %s", got["nginx:latest"].DiunVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookHandler(t *testing.T) {
|
||||||
|
diun.UpdatesReset() // reset global state
|
||||||
|
event := diun.DiunEvent{
|
||||||
|
DiunVersion: "2.0",
|
||||||
|
Hostname: "host2",
|
||||||
|
Status: "updated",
|
||||||
|
Provider: "docker",
|
||||||
|
Image: "alpine:latest",
|
||||||
|
HubLink: "https://hub.docker.com/alpine",
|
||||||
|
MimeType: "application/json",
|
||||||
|
Digest: "sha256:def",
|
||||||
|
Created: time.Now(),
|
||||||
|
Platform: "linux/amd64",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(event)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
diun.WebhookHandler(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(diun.GetUpdatesMap()) != 1 {
|
||||||
|
t.Errorf("expected 1 update, got %d", len(diun.GetUpdatesMap()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookHandler_BadRequest(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json")))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
diun.WebhookHandler(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for bad JSON, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatesHandler(t *testing.T) {
|
||||||
|
diun.UpdatesReset() // reset global state
|
||||||
|
event := diun.DiunEvent{Image: "busybox:latest"}
|
||||||
|
diun.UpdateEvent(event)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/updates", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
diun.UpdatesHandler(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var got map[string]diun.DiunEvent
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got["busybox:latest"]; !ok {
|
||||||
|
t.Errorf("expected busybox:latest in updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for simulating a broken ResponseWriter
|
||||||
|
// Used in TestUpdatesHandler_EncodeError
|
||||||
|
type failWriter struct{ http.ResponseWriter }
|
||||||
|
|
||||||
|
func (f failWriter) Header() http.Header { return http.Header{} }
|
||||||
|
func (f failWriter) Write([]byte) (int, error) { return 0, errors.New("forced error") }
|
||||||
|
func (f failWriter) WriteHeader(_ int) {}
|
||||||
|
|
||||||
|
func TestUpdatesHandler_EncodeError(t *testing.T) {
|
||||||
|
rec := failWriter{httptest.NewRecorder()}
|
||||||
|
diun.UpdatesHandler(rec, httptest.NewRequest(http.MethodGet, "/api/updates", nil))
|
||||||
|
// No panic = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainHandlerIntegration(t *testing.T) {
|
||||||
|
diun.UpdatesReset() // reset global state
|
||||||
|
// Start test server
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/webhook" {
|
||||||
|
diun.WebhookHandler(w, r)
|
||||||
|
} else if r.URL.Path == "/api/updates" {
|
||||||
|
diun.UpdatesHandler(w, r)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Post event
|
||||||
|
event := diun.DiunEvent{Image: "integration:latest"}
|
||||||
|
body, _ := json.Marshal(event)
|
||||||
|
resp, err := http.Post(ts.URL+"/webhook", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webhook POST failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("webhook POST returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if cerr := resp.Body.Close(); cerr != nil {
|
||||||
|
t.Errorf("failed to close response body: %v", cerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updates
|
||||||
|
resp, err = http.Get(ts.URL + "/api/updates")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/updates failed: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := resp.Body.Close(); cerr != nil {
|
||||||
|
t.Errorf("failed to close response body: %v", cerr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var got map[string]diun.DiunEvent
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got["integration:latest"]; !ok {
|
||||||
|
t.Errorf("expected integration:latest in updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user