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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.local/
.env
dist/
tmp/
.git/
5 changes: 5 additions & 0 deletions .github/.dependabot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ updates:
directory: "/"
schedule:
interval: "daily"

- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ jobs:
with:
go-version: '1.24'

- name: Run tests
- name: Run unit tests
run: make test

- name: Run tests
- name: Run integration tests
run: make test-verify-verbose

build:
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM golang:1.24 AS builder

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,15 @@ test-verify:
test-verify-verbose:
go run ./cmd/verify -verbose

.PHONY: server-up
server-up:
docker compose up -d --build

.PHONY: server-logs
server-logs:
docker compose logs -f server

.PHONY: server-stop
server-stop:
docker compose down --rmi local --remove-orphans

File renamed without changes.
91 changes: 66 additions & 25 deletions cmd/lambda/main.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"

awsevents "github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/cockroachdb/errors"
"github.com/cruxstack/github-ops-app/internal/app"
"github.com/cruxstack/github-ops-app/internal/config"
)

var (
initOnce sync.Once
appInst *app.App
router http.Handler
logger *slog.Logger
initErr error
)
Expand All @@ -27,14 +32,19 @@ func initApp() {

cfg, err := config.NewConfig()
if err != nil {
initErr = fmt.Errorf("config init failed: %w", err)
initErr = errors.Wrap(err, "config init failed")
return
}
appInst, initErr = app.New(context.Background(), cfg)
appInst, initErr = app.NewApp(context.Background(), cfg, logger)
if initErr != nil {
return
}
router = appInst.Handler()
})
}

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

headers := make(map[string]string)
for key, value := range req.Headers {
headers[strings.ToLower(key)] = value
httpReq, err := http.NewRequestWithContext(
ctx,
req.RequestContext.HTTP.Method,
req.RawPath,
strings.NewReader(req.Body),
)
if err != nil {
return awsevents.APIGatewayV2HTTPResponse{
StatusCode: 500,
Body: "failed to construct http request",
}, nil
}

appReq := app.Request{
Type: app.RequestTypeHTTP,
Method: req.RequestContext.HTTP.Method,
Path: req.RawPath,
Headers: headers,
Body: []byte(req.Body),
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}

resp := appInst.HandleRequest(ctx, appReq)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httpReq)

return awsevents.APIGatewayV2HTTPResponse{
StatusCode: resp.StatusCode,
Headers: resp.Headers,
Body: string(resp.Body),
StatusCode: rec.Code,
Headers: flattenHeaders(rec.Header()),
Body: rec.Body.String(),
}, nil
}

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

req := app.Request{
Type: app.RequestTypeScheduled,
ScheduledAction: detail.Action,
ScheduledData: detail.Data,
path := fmt.Sprintf("%s/scheduled/%s", appInst.Config.BasePath, detail.Action)

var body []byte
if detail.Data != nil {
body = detail.Data
}

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

if resp.StatusCode >= 400 {
return fmt.Errorf("scheduled event failed: %s", string(resp.Body))
if appInst.Config.AdminToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+appInst.Config.AdminToken)
}
if len(body) > 0 {
httpReq.Header.Set("Content-Type", "application/json")
}

rec := httptest.NewRecorder()
router.ServeHTTP(rec, httpReq)

if rec.Code >= 400 {
return errors.Newf("scheduled event failed: %s", rec.Body.String())
}

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

return nil, fmt.Errorf("unknown lambda event type")
return nil, errors.New("unknown lambda event type")
}

// flattenHeaders converts multi-value http.Header to single-value map.
func flattenHeaders(h http.Header) map[string]string {
flat := make(map[string]string, len(h))
for key, values := range h {
if len(values) > 0 {
flat[key] = values[0]
}
}
return flat
}

func main() {
Expand Down
47 changes: 39 additions & 8 deletions cmd/sample/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"

Expand All @@ -32,12 +36,14 @@ func main() {
os.Exit(1)
}

a, err := app.New(ctx, cfg)
a, err := app.NewApp(ctx, cfg, logger)
if err != nil {
logger.Error("failed to initialize app", slog.String("error", err.Error()))
os.Exit(1)
}

router := a.Handler()

path := filepath.Join("fixtures", "samples.json")
raw, err := os.ReadFile(path)
if err != nil {
Expand All @@ -52,28 +58,53 @@ func main() {
}

for i, sample := range samples {
eventType := sample["event_type"].(string)
eventType, ok := sample["event_type"].(string)
if !ok {
logger.Error("missing or invalid event_type", slog.Int("sample", i))
os.Exit(1)
}

switch eventType {
case "okta_sync":
evt := app.ScheduledEvent{
Action: "okta-sync",
reqPath := fmt.Sprintf("%s/scheduled/okta-sync", cfg.BasePath)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, nil)
if err != nil {
logger.Error("failed to construct http request",
slog.Int("sample", i),
slog.String("error", err.Error()))
os.Exit(1)
}
if cfg.AdminToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.AdminToken)
}
if err := a.ProcessScheduledEvent(ctx, evt); err != nil {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httpReq)
if rec.Code >= 400 {
logger.Error("failed to process okta_sync sample",
slog.Int("sample", i),
slog.String("error", err.Error()))
slog.String("response", rec.Body.String()))
os.Exit(1)
}

case "pr_webhook":
payload, _ := json.Marshal(sample["payload"])
if err := a.ProcessWebhook(ctx, payload, "pull_request"); err != nil {
logger.Error("failed to process pr_webhook sample",
reqPath := fmt.Sprintf("%s/webhooks", cfg.BasePath)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, bytes.NewReader(payload))
if err != nil {
logger.Error("failed to construct http request",
slog.Int("sample", i),
slog.String("error", err.Error()))
os.Exit(1)
}
httpReq.Header.Set("X-GitHub-Event", "pull_request")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httpReq)
if rec.Code >= 400 {
logger.Error("failed to process pr_webhook sample",
slog.Int("sample", i),
slog.String("response", rec.Body.String()))
os.Exit(1)
}

default:
logger.Info("skipping unknown event type", slog.String("event_type", eventType))
Expand Down
Loading