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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ go.work.sum
/bin/
/site/

# UI build outputs (regenerated by `make ui`; only .gitkeep is tracked
# under internal/ui/dist so the //go:embed directive has something to
# embed when the SPA hasn't been built yet)
/ui/dist/
/ui/node_modules/
/ui/tsconfig.tsbuildinfo
/internal/ui/dist/*
!/internal/ui/dist/.gitkeep

# Dev secrets (generated by `make dev-secrets`)
.env.dev

Expand Down
42 changes: 42 additions & 0 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
#
# Dockerfile for compose / local development. Builds the SPA, builds the
# Go binary, and ships it in a distroless static image. Slower than the
# goreleaser-shaped Dockerfile (which expects a pre-built binary), but
# self-contained: `docker compose build` Just Works.

FROM node:22-alpine AS ui
WORKDIR /src/ui
COPY ui/package.json ui/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY ui/ ./
RUN pnpm build

FROM golang:1.26-alpine AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui /src/ui/dist /src/internal/ui/dist
ARG VERSION=dev
ARG COMMIT=unknown
ARG DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-s -w -X github.com/plexara/api-test/pkg/build.Version=${VERSION} -X github.com/plexara/api-test/pkg/build.Commit=${COMMIT} -X github.com/plexara/api-test/pkg/build.Date=${DATE}" \
-o /out/api-test ./cmd/api-test

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api-test /usr/local/bin/api-test
COPY configs/api-test.example.yaml /app/configs/api-test.yaml

USER nonroot:nonroot
EXPOSE 8080

# Distroless has no shell so we use the binary itself for healthcheck.
# /healthz returns 200 from the unauthenticated health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["/usr/local/bin/api-test", "--healthcheck"] || exit 1

ENTRYPOINT ["/usr/local/bin/api-test"]
CMD ["--config", "/app/configs/api-test.yaml"]
109 changes: 100 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ CODEQL_RESULT := $(BUILD_DIR)/codeql-results.sarif

.PHONY: all build build-all test test-short bench fmt fmt-check vet tidy \
mod-tidy-check mod-verify clean help dev-secrets \
ui ui-dev ui-clean embed-clean \
ui ui-dev ui-clean ui-verify embed-clean \
lint security gosec govulncheck semgrep \
coverage coverage-gate coverage-report \
integration codeql require-docker require-codeql require-semgrep require-jq \
integration codeql require-docker require-codeql require-semgrep require-jq require-node \
verify tools-check tools-install \
dev dev-anon dev-up dev-wait dev-ui-if-needed dev-down dev-logs \
docker docs docs-serve run version
Expand All @@ -79,6 +79,39 @@ build-all:
@echo "Building all packages..."
$(GOBUILD) -v ./...

## ui: Build the SPA into internal/ui/dist for embedding into the binary.
## Uses pnpm with --frozen-lockfile so reproducible.
ui: require-node
@echo "Building UI..."
cd $(UI_DIR) && pnpm install --frozen-lockfile && pnpm build
@rm -rf $(UI_EMBED_DIR)
@cp -R $(UI_DIR)/dist $(UI_EMBED_DIR)
@echo "UI built and copied to $(UI_EMBED_DIR)."

## ui-verify: TypeScript + Vite build of the SPA without copying to the
## embed dir. Mirrored from CI's frontend job — catches type
## errors and broken imports without rebuilding the Go binary.
ui-verify: require-node
@echo "Verifying UI (typecheck + build)..."
cd $(UI_DIR) && pnpm install --frozen-lockfile && pnpm build

## ui-dev: Run Vite dev server (proxies /api to localhost:8080).
ui-dev:
cd $(UI_DIR) && pnpm dev

## ui-clean: Remove UI build artifacts (dist + node_modules).
ui-clean:
@rm -rf $(UI_DIR)/dist $(UI_DIR)/node_modules

## embed-clean: Reset internal/ui/dist to .gitkeep only (matches a clean
## CI checkout; useful before `make ui` to confirm the build
## produces a complete dist tree from scratch).
embed-clean:
@echo "Cleaning UI embed directory..."
@rm -rf $(UI_EMBED_DIR)
@mkdir -p $(UI_EMBED_DIR)
@touch $(UI_EMBED_DIR)/.gitkeep

## test: Run unit tests with race detector
test:
@echo "Running tests..."
Expand Down Expand Up @@ -267,6 +300,14 @@ require-jq:
exit 1; \
fi

require-node:
@if ! command -v pnpm >/dev/null 2>&1; then \
echo "FAIL: pnpm not on PATH (UI build needs it)." >&2; \
echo " brew install pnpm # macOS" >&2; \
echo " npm install -g pnpm # any node install" >&2; \
exit 1; \
fi

## tools-install: Install lint/security tools at the pinned versions into $(TOOLS_DIR).
TOOLS_STAMP := $(TOOLS_DIR)/.installed-$(GOLANGCI_LINT_VERSION)-$(GOSEC_VERSION)
tools-install: $(TOOLS_STAMP)
Expand Down Expand Up @@ -302,7 +343,7 @@ tools-check: tools-install
## - docker (running) for `make integration`
## - codeql for `make codeql`
## - semgrep for `make semgrep`
verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security coverage-gate integration codeql
verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security coverage-gate integration codeql ui-verify
@echo ""
@echo "=== verify: all checks passed (CI-equivalent set) ==="
@# Pre-commit gate sentinel: record the current diff hash so the
Expand All @@ -312,11 +353,31 @@ verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security
@{ git diff --cached HEAD 2>/dev/null; git diff 2>/dev/null; } \
| shasum -a 256 | cut -c1-16 > .claude/.last-verify-passed

## dev-anon: Run anonymous-mode dev binary; no DB, no auth (M1 happy path).
dev-anon:
$(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.dev.yaml
## dev: One-command full local stack — postgres + keycloak in docker,
## SPA built if missing, binary in foreground against the live config.
## Generates .env.dev with random secrets on first run (gitignored;
## subsequent runs reuse so portal sessions persist).
dev: dev-secrets dev-up dev-wait dev-ui-if-needed
@. ./.env.dev && \
echo "" && \
echo "Starting api-test (config: configs/api-test.live.yaml)..." && \
echo " Portal: http://localhost:8080/portal/ (sign in with dev/dev or paste an API key)" && \
echo " /v1/*: http://localhost:8080/v1/... (X-API-Key: \$$APITEST_DEV_KEY)" && \
echo " Keycloak: http://localhost:8081/ (admin/admin)" && \
echo " API key: $$APITEST_DEV_KEY" && \
echo " Bearer: $$APITEST_DEV_BEARER" && \
echo "" && \
$(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.live.yaml

## dev-anon: Run anonymous-mode dev binary against postgres only — no
## Keycloak, no auth required, no portal browser login.
## Fastest iteration for endpoint-group / audit work.
dev-anon: dev-secrets
@. ./.env.dev && docker compose -f docker-compose.dev.yml up -d postgres
@. ./.env.dev && $(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.dev.yaml

## dev-secrets: Generate .env.dev with random cookie secret + dev API key on first run.
## Re-run-safe; only writes if .env.dev is missing.
dev-secrets:
@if [ ! -f .env.dev ]; then \
echo "Generating .env.dev with random secrets (gitignored)..."; \
Expand All @@ -328,9 +389,39 @@ dev-secrets:
chmod 600 .env.dev; \
fi

## dev: Full local stack (M3+). For now, points at dev-anon.
## M3 will replace with: postgres + keycloak in compose, binary in foreground.
dev: dev-anon
## dev-up: Start the dev stack (postgres + keycloak) without the binary.
## Depends on dev-secrets because docker compose interpolates the
## APITEST_COOKIE_SECRET reference at parse time even when the
## api-test service isn't being started.
dev-up: dev-secrets require-docker
@. ./.env.dev && docker compose -f docker-compose.dev.yml up -d postgres keycloak

## dev-wait: Block until postgres and keycloak are reachable.
## Sources .env.dev because `docker compose exec` re-parses the
## compose file and its APITEST_* references must resolve.
dev-wait: dev-secrets
@echo "Waiting for Postgres..."
@. ./.env.dev && until docker compose -f docker-compose.dev.yml exec -T postgres pg_isready -U api >/dev/null 2>&1; do sleep 1; done
@echo "Waiting for Keycloak realm..."
@until curl -fs http://localhost:8081/realms/api-test/.well-known/openid-configuration >/dev/null 2>&1; do sleep 2; done
@echo "Stack ready."

## dev-ui-if-needed: Build the SPA if internal/ui/dist/index.html is missing.
## Skipped silently when the embed is already populated.
dev-ui-if-needed:
@if [ ! -f $(UI_EMBED_DIR)/index.html ]; then \
$(MAKE) ui; \
fi

## dev-down: Stop the dev stack and remove the compose network. Volumes
## (postgres data) are kept; add `-v` to the underlying command
## to wipe them.
dev-down: dev-secrets
@. ./.env.dev && docker compose -f docker-compose.dev.yml down

## dev-logs: Tail compose logs (postgres + keycloak + binary if running).
dev-logs: dev-secrets
@. ./.env.dev && docker compose -f docker-compose.dev.yml logs -f --tail=100

## run: Build and run with dev config
run: build
Expand Down
1 change: 1 addition & 0 deletions configs/api-test.live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ oidc:
enabled: true
issuer: "http://localhost:8081/realms/api-test"
audience: "api-test"
client_id: "api-test-portal"
allowed_clients: ["api-test-portal", "plexara-cc", "plexara-ac"]
clock_skew_seconds: 30
jwks_cache_ttl: 1h
Expand Down
112 changes: 112 additions & 0 deletions dev/keycloak/api-test-realm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"realm": "api-test",
"enabled": true,
"displayName": "api-test (development)",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,

"clients": [
{
"clientId": "api-test-portal",
"name": "api-test portal (browser PKCE)",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"implicitFlowEnabled": false,
"redirectUris": [
"http://localhost:8080/portal/auth/callback"
],
"webOrigins": [
"http://localhost:8080"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"protocolMappers": [
{
"name": "api-test-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "api-test",
"id.token.claim": "true",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "plexara-cc",
"name": "Plexara API gateway (oauth2_client_credentials connections)",
"enabled": true,
"publicClient": false,
"secret": "dev-plexara-cc-client-secret",
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"protocolMappers": [
{
"name": "api-test-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "api-test",
"id.token.claim": "true",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "plexara-ac",
"name": "Plexara API gateway (oauth2_authorization_code connections)",
"enabled": true,
"publicClient": false,
"secret": "dev-plexara-ac-client-secret",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://localhost:9000/api/v1/admin/api-gateway/oauth/callback",
"http://localhost:8080/*"
],
"protocolMappers": [
{
"name": "api-test-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "api-test",
"id.token.claim": "true",
"access.token.claim": "true"
}
}
]
}
],

"users": [
{
"username": "dev",
"enabled": true,
"emailVerified": true,
"firstName": "Dev",
"lastName": "User",
"email": "dev@example.com",
"credentials": [
{
"type": "password",
"value": "dev",
"temporary": false
}
]
}
]
}
Loading
Loading