Skip to content

Commit 137ae3a

Browse files
authored
Merge pull request #26 from cruxstack/dev
refactor: restructure packages, add chi router, and improve test coverage
2 parents 73c627d + 7a620fa commit 137ae3a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3073
-907
lines changed

.dockerignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.local/
2+
.env
3+
dist/
4+
tmp/
5+
.git/

.github/.dependabot.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ updates:
44
directory: "/"
55
schedule:
66
interval: "daily"
7+
8+
- package-ecosystem: "gomod"
9+
directory: "/"
10+
schedule:
11+
interval: "weekly"

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ jobs:
4646
with:
4747
go-version: '1.24'
4848

49-
- name: Run tests
49+
- name: Run unit tests
5050
run: make test
5151

52-
- name: Run tests
52+
- name: Run integration tests
5353
run: make test-verify-verbose
5454

5555
build:

Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM golang:1.24 AS builder
2+
3+
WORKDIR /src
4+
COPY go.mod go.sum ./
5+
RUN go mod download
6+
COPY . .
7+
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /server ./cmd/server
8+
9+
FROM gcr.io/distroless/static-debian12
10+
COPY --from=builder /server /server
11+
ENTRYPOINT ["/server"]

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,15 @@ test-verify:
4343
test-verify-verbose:
4444
go run ./cmd/verify -verbose
4545

46+
.PHONY: server-up
47+
server-up:
48+
docker compose up -d --build
49+
50+
.PHONY: server-logs
51+
server-logs:
52+
docker compose logs -f server
53+
54+
.PHONY: server-stop
55+
server-stop:
56+
docker compose down --rmi local --remove-orphans
57+

cmd/lambda/main.go

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
package main
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
78
"log/slog"
9+
"net/http"
10+
"net/http/httptest"
811
"strings"
912
"sync"
1013

1114
awsevents "github.com/aws/aws-lambda-go/events"
1215
"github.com/aws/aws-lambda-go/lambda"
16+
"github.com/cockroachdb/errors"
1317
"github.com/cruxstack/github-ops-app/internal/app"
1418
"github.com/cruxstack/github-ops-app/internal/config"
1519
)
1620

1721
var (
1822
initOnce sync.Once
1923
appInst *app.App
24+
router http.Handler
2025
logger *slog.Logger
2126
initErr error
2227
)
@@ -27,14 +32,19 @@ func initApp() {
2732

2833
cfg, err := config.NewConfig()
2934
if err != nil {
30-
initErr = fmt.Errorf("config init failed: %w", err)
35+
initErr = errors.Wrap(err, "config init failed")
3136
return
3237
}
33-
appInst, initErr = app.New(context.Background(), cfg)
38+
appInst, initErr = app.NewApp(context.Background(), cfg, logger)
39+
if initErr != nil {
40+
return
41+
}
42+
router = appInst.Handler()
3443
})
3544
}
3645

37-
// APIGatewayHandler converts API Gateway requests to unified app.Request.
46+
// APIGatewayHandler converts API Gateway requests to stdlib *http.Request
47+
// and routes them through the chi router.
3848
func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayV2HTTPRequest) (awsevents.APIGatewayV2HTTPResponse, error) {
3949
initApp()
4050
if initErr != nil {
@@ -50,29 +60,35 @@ func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayV2HTTPReques
5060
logger.Debug("received api gateway request", slog.String("request", string(j)))
5161
}
5262

53-
headers := make(map[string]string)
54-
for key, value := range req.Headers {
55-
headers[strings.ToLower(key)] = value
63+
httpReq, err := http.NewRequestWithContext(
64+
ctx,
65+
req.RequestContext.HTTP.Method,
66+
req.RawPath,
67+
strings.NewReader(req.Body),
68+
)
69+
if err != nil {
70+
return awsevents.APIGatewayV2HTTPResponse{
71+
StatusCode: 500,
72+
Body: "failed to construct http request",
73+
}, nil
5674
}
5775

58-
appReq := app.Request{
59-
Type: app.RequestTypeHTTP,
60-
Method: req.RequestContext.HTTP.Method,
61-
Path: req.RawPath,
62-
Headers: headers,
63-
Body: []byte(req.Body),
76+
for key, value := range req.Headers {
77+
httpReq.Header.Set(key, value)
6478
}
6579

66-
resp := appInst.HandleRequest(ctx, appReq)
80+
rec := httptest.NewRecorder()
81+
router.ServeHTTP(rec, httpReq)
6782

6883
return awsevents.APIGatewayV2HTTPResponse{
69-
StatusCode: resp.StatusCode,
70-
Headers: resp.Headers,
71-
Body: string(resp.Body),
84+
StatusCode: rec.Code,
85+
Headers: flattenHeaders(rec.Header()),
86+
Body: rec.Body.String(),
7287
}, nil
7388
}
7489

75-
// EventBridgeHandler converts EventBridge events to unified app.Request.
90+
// EventBridgeHandler converts EventBridge events to POST /scheduled/{action}
91+
// requests and routes them through the chi router.
7692
func EventBridgeHandler(ctx context.Context, evt awsevents.CloudWatchEvent) error {
7793
initApp()
7894
if initErr != nil {
@@ -90,16 +106,30 @@ func EventBridgeHandler(ctx context.Context, evt awsevents.CloudWatchEvent) erro
90106
return err
91107
}
92108

93-
req := app.Request{
94-
Type: app.RequestTypeScheduled,
95-
ScheduledAction: detail.Action,
96-
ScheduledData: detail.Data,
109+
path := fmt.Sprintf("%s/scheduled/%s", appInst.Config.BasePath, detail.Action)
110+
111+
var body []byte
112+
if detail.Data != nil {
113+
body = detail.Data
97114
}
98115

99-
resp := appInst.HandleRequest(ctx, req)
116+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body))
117+
if err != nil {
118+
return errors.Wrap(err, "failed to construct http request")
119+
}
100120

101-
if resp.StatusCode >= 400 {
102-
return fmt.Errorf("scheduled event failed: %s", string(resp.Body))
121+
if appInst.Config.AdminToken != "" {
122+
httpReq.Header.Set("Authorization", "Bearer "+appInst.Config.AdminToken)
123+
}
124+
if len(body) > 0 {
125+
httpReq.Header.Set("Content-Type", "application/json")
126+
}
127+
128+
rec := httptest.NewRecorder()
129+
router.ServeHTTP(rec, httpReq)
130+
131+
if rec.Code >= 400 {
132+
return errors.Newf("scheduled event failed: %s", rec.Body.String())
103133
}
104134

105135
return nil
@@ -122,7 +152,18 @@ func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) {
122152
return nil, EventBridgeHandler(ctx, eventBridgeEvent)
123153
}
124154

125-
return nil, fmt.Errorf("unknown lambda event type")
155+
return nil, errors.New("unknown lambda event type")
156+
}
157+
158+
// flattenHeaders converts multi-value http.Header to single-value map.
159+
func flattenHeaders(h http.Header) map[string]string {
160+
flat := make(map[string]string, len(h))
161+
for key, values := range h {
162+
if len(values) > 0 {
163+
flat[key] = values[0]
164+
}
165+
}
166+
return flat
126167
}
127168

128169
func main() {

cmd/sample/main.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
package main
55

66
import (
7+
"bytes"
78
"context"
89
"encoding/json"
10+
"fmt"
911
"log/slog"
12+
"net/http"
13+
"net/http/httptest"
1014
"os"
1115
"path/filepath"
1216

@@ -32,12 +36,14 @@ func main() {
3236
os.Exit(1)
3337
}
3438

35-
a, err := app.New(ctx, cfg)
39+
a, err := app.NewApp(ctx, cfg, logger)
3640
if err != nil {
3741
logger.Error("failed to initialize app", slog.String("error", err.Error()))
3842
os.Exit(1)
3943
}
4044

45+
router := a.Handler()
46+
4147
path := filepath.Join("fixtures", "samples.json")
4248
raw, err := os.ReadFile(path)
4349
if err != nil {
@@ -52,28 +58,53 @@ func main() {
5258
}
5359

5460
for i, sample := range samples {
55-
eventType := sample["event_type"].(string)
61+
eventType, ok := sample["event_type"].(string)
62+
if !ok {
63+
logger.Error("missing or invalid event_type", slog.Int("sample", i))
64+
os.Exit(1)
65+
}
5666

5767
switch eventType {
5868
case "okta_sync":
59-
evt := app.ScheduledEvent{
60-
Action: "okta-sync",
69+
reqPath := fmt.Sprintf("%s/scheduled/okta-sync", cfg.BasePath)
70+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, nil)
71+
if err != nil {
72+
logger.Error("failed to construct http request",
73+
slog.Int("sample", i),
74+
slog.String("error", err.Error()))
75+
os.Exit(1)
76+
}
77+
if cfg.AdminToken != "" {
78+
httpReq.Header.Set("Authorization", "Bearer "+cfg.AdminToken)
6179
}
62-
if err := a.ProcessScheduledEvent(ctx, evt); err != nil {
80+
rec := httptest.NewRecorder()
81+
router.ServeHTTP(rec, httpReq)
82+
if rec.Code >= 400 {
6383
logger.Error("failed to process okta_sync sample",
6484
slog.Int("sample", i),
65-
slog.String("error", err.Error()))
85+
slog.String("response", rec.Body.String()))
6686
os.Exit(1)
6787
}
6888

6989
case "pr_webhook":
7090
payload, _ := json.Marshal(sample["payload"])
71-
if err := a.ProcessWebhook(ctx, payload, "pull_request"); err != nil {
72-
logger.Error("failed to process pr_webhook sample",
91+
reqPath := fmt.Sprintf("%s/webhooks", cfg.BasePath)
92+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, bytes.NewReader(payload))
93+
if err != nil {
94+
logger.Error("failed to construct http request",
7395
slog.Int("sample", i),
7496
slog.String("error", err.Error()))
7597
os.Exit(1)
7698
}
99+
httpReq.Header.Set("X-GitHub-Event", "pull_request")
100+
rec := httptest.NewRecorder()
101+
router.ServeHTTP(rec, httpReq)
102+
if rec.Code >= 400 {
103+
logger.Error("failed to process pr_webhook sample",
104+
slog.Int("sample", i),
105+
slog.String("response", rec.Body.String()))
106+
os.Exit(1)
107+
}
77108

78109
default:
79110
logger.Info("skipping unknown event type", slog.String("event_type", eventType))

0 commit comments

Comments
 (0)