diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..331604e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Local build artifacts. Without this, `docker build` sends rust/target +# and ./artf-agent (hundreds of MB) as context. +artf-agent +artf-agent.exe +rust/target +coverage.out +coverage.html +.git +.DS_Store +*.log +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile index d73ff07..10a63ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,9 +84,9 @@ USER nobody # Expose ports (gRPC: 50051, Web/MCP: 8081, Health: 8080) EXPOSE 50051 8081 8080 -# Health check +# Health check uses the same binary (the image has no curl/wget). HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/artf-agent", "-health-check"] || exit 1 + CMD ["/artf-agent", "-health-check"] # Set entrypoint ENTRYPOINT ["/artf-agent"] diff --git a/Makefile b/Makefile index 5f032ff..d3274d1 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,55 @@ # Agentic RTB Framework Makefile BINARY=artf-agent +IMAGE ?= artf-agent LANGUAGES=go # cpp go csharp objc python ruby js +RUST_BINARY=rust/target/release/agentic-rtb-framework-service +GRPC_ADDR ?= localhost:50051 +HEALTH_URL ?= http://localhost:8080 +SAMPLE_SERVICE := com.iabtechlab.bidstream.mutation.services.v1.RTBExtensionPoint/GetMutations + +.DEFAULT_GOAL := build + +.PHONY: help deps generate build run run-dev run-all run-grpc run-mcp run-web \ + test test-coverage lint \ + build-rust run-rust build-all \ + docker-build docker-run docker-run-all docker-compose-up docker-compose-down \ + health-check grpc-test sample-banner sample-video sample-bidshade \ + bindings check clean docs watch + +help: + @echo "Reference agents (checked-in pkg/pb/; protoc not required):" + @echo " make deps # go mod download" + @echo " make build # Go agent -> ./$(BINARY)" + @echo " make build-rust # Rust reference service" + @echo " make test # go test ./..." + @echo " make docker-build # container image $(IMAGE)" + @echo "" + @echo "Regenerate protobufs (requires protoc; not needed to build the agents):" + @echo " make generate # Go: scripts/generate.sh" + @echo " make bindings # spec language bindings (repo-root protos)" + @echo "" + @echo "Run (requires a built binary):" + @echo " make run-all # gRPC + MCP + web + health" + @echo " make health-check # curl $(HEALTH_URL)/health/{live,ready}" + @echo " make grpc-test # grpcurl sample against $(GRPC_ADDR)" # Go build and run targets -.PHONY: build run-all run-grpc run-mcp run-web test + +deps: + go mod download + +# Go protobuf regen. Not required to build; pkg/pb/ is checked in. +generate: + scripts/generate.sh build: go build -o $(BINARY) ./cmd/agent +run: run-all + +run-dev: run-all + run-all: build ./$(BINARY) --enable-grpc --enable-mcp --enable-web @@ -24,10 +65,14 @@ run-web: build test: go test ./... -# Rust build and run targets -RUST_BINARY=rust/target/release/agentic-rtb-framework-service +test-coverage: + go test ./... -coverprofile=coverage.out + go tool cover -func=coverage.out + +lint: + go vet ./... -.PHONY: build-rust run-rust build-all +# Rust build and run targets build-rust: cd rust && cargo build --release @@ -37,6 +82,41 @@ run-rust: build-rust build-all: build build-rust +# Docker + +docker-build: + docker build -t $(IMAGE) . + +docker-run: docker-run-all + +docker-run-all: docker-build + docker run --rm -p 50051:50051 -p 8081:8081 -p 8080:8080 $(IMAGE) + +docker-compose-up: + docker compose up --build -d + +docker-compose-down: + docker compose down + +# Live checks (server must already be running) + +health-check: + curl -fsS $(HEALTH_URL)/health/live + @echo + curl -fsS $(HEALTH_URL)/health/ready + @echo + +grpc-test: sample-banner + +sample-banner: + grpcurl -plaintext -d @ $(GRPC_ADDR) $(SAMPLE_SERVICE) < samples/banner-basic.json + +sample-video: + grpcurl -plaintext -d @ $(GRPC_ADDR) $(SAMPLE_SERVICE) < samples/video-deals.json + +sample-bidshade: + grpcurl -plaintext -d @ $(GRPC_ADDR) $(SAMPLE_SERVICE) < samples/bid-shading.json + # Protobuf targets bindings: @@ -58,6 +138,7 @@ clean: for x in ${LANGUAGES}; do \ rm -fr $${x}/*; \ done + rm -f $(BINARY) coverage.out coverage.html docs: podman run --rm \ diff --git a/README.md b/README.md index 0bfcc9a..7d8d56c 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,15 @@ This project implements a multi-protocol server that conforms to the ARTF specif #### Prerequisites -- Go 1.23+ -- Protocol Buffers compiler (`protoc`) v3.21+ +- Go 1.23+ (required to build the Go agent) +- Rust toolchain (optional, for `make build-rust`) - Docker (optional, for containerized deployment) -#### Critical Dependencies +Checked-in generated Go in `pkg/pb/` is enough to `make build`. You do not need to download OpenRTB or run `protoc` to build the agent. -The following tools must be installed to generate protobuf code: +#### Optional: regenerating protobuf code + +`make generate` runs `scripts/generate.sh`. It is not required to build. The tools below are only needed for that regen: | Tool | Version | Installation | |------|---------|--------------| @@ -79,19 +81,19 @@ Go module dependencies (managed via `go.mod`): #### Build and Run ```bash -# Install dependencies +# Install Go module dependencies make deps -# Generate protobuf code -make generate - -# Build the server +# Build the Go server (uses checked-in pkg/pb/; protoc not required) make build +# Optional: build the Rust reference service +make build-rust + # Run with all services enabled make run-all -# Run in development mode (verbose) +# Same as make run-all make run-dev ``` @@ -169,6 +171,7 @@ The MCP server exposes an `extend_rtb` tool that accepts OpenRTB bid requests an | `--mcp-port` | 50052 | MCP server port (ignored when both Web and MCP enabled) | | `--web-port` | 8081 | Web interface port | | `--health-port` | 8080 | Health check HTTP port | +| `--health-check` | false | Probe `http://127.0.0.1:/health/ready` and exit (Docker HEALTHCHECK) | #### Load Balancer Configuration @@ -193,16 +196,13 @@ make test # Run with coverage make test-coverage -# Test gRPC endpoint (requires grpcurl) +# Test gRPC endpoint (requires a running agent and grpcurl) make grpc-test -# Test MCP endpoint -make mcp-test - -# Check health endpoints +# Check health endpoints (requires a running agent) make health-check -# Send sample requests via MCP +# Send bundled samples over gRPC (requires a running agent and grpcurl) make sample-banner make sample-video make sample-bidshade diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 867a691..a0b987b 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -72,6 +72,9 @@ var ( // Version flag showVersion = flag.Bool("version", false, "Show version information") + + // One-shot probe for Docker HEALTHCHECK / compose. Does not start the server. + healthCheck = flag.Bool("health-check", false, "Probe local /health/ready and exit 0/1") ) func main() { @@ -83,6 +86,17 @@ func main() { os.Exit(0) } + if *healthCheck { + origin := fmt.Sprintf("http://127.0.0.1:%d", *healthPort) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := health.Probe(ctx, origin); err != nil { + log.Printf("health-check: %v", err) + os.Exit(1) + } + os.Exit(0) + } + log.Printf("Starting ARTF Agent v%s", Version) log.Printf("Features: gRPC=%v, MCP=%v, Web=%v", *enableGRPC, *enableMCP, *enableWeb) diff --git a/docker-compose.yml b/docker-compose.yml index 8438cee..8aa0ee1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: - "--web-port=8081" - "--health-port=8080" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health/ready"] + test: ["CMD", "/artf-agent", "-health-check"] interval: 10s timeout: 5s retries: 3 @@ -61,7 +61,7 @@ services: - "--enable-mcp=false" - "--enable-web=false" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health/ready"] + test: ["CMD", "/artf-agent", "-health-check"] interval: 10s timeout: 5s retries: 3 @@ -87,7 +87,7 @@ services: - "--enable-mcp" - "--enable-web=false" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health/ready"] + test: ["CMD", "/artf-agent", "-health-check"] interval: 10s timeout: 5s retries: 3 diff --git a/docs/00-EXAMPLE.md b/docs/00-EXAMPLE.md index ac8a9d8..76e416d 100644 --- a/docs/00-EXAMPLE.md +++ b/docs/00-EXAMPLE.md @@ -315,28 +315,29 @@ readinessProbe: ### Prerequisites -- Go 1.22+ -- Protocol Buffers compiler (`protoc`) -- protoc-gen-go and protoc-gen-go-grpc plugins -- Docker (for containerized deployment) +- Go 1.23+ (required to `make build`) +- Protocol Buffers compiler (`protoc`) and Go plugins (optional: `make generate`) +- Docker (optional, for containerized deployment) ### Build Commands | Command | Description | |---------|-------------| | `make deps` | Download Go dependencies | -| `make generate` | Generate protobuf Go code | -| `make build` | Build server binary | +| `make generate` | Regenerate protobuf Go via `scripts/generate.sh` (requires protoc; not required to `make build`) | +| `make build` | Build the Go agent binary from checked-in `pkg/pb/` | +| `make build-rust` | Build the Rust reference service (`rust/Cargo.toml`) | | `make test` | Run unit tests | | `make test-coverage` | Run tests with coverage report | -| `make lint` | Run linter | -| `make clean` | Remove build artifacts | +| `make lint` | `go vet ./...` | +| `make clean` | Remove spec language output dirs, `artf-agent`, and coverage files | ### Run Commands | Command | Description | |---------|-------------| -| `make run` | Run server locally | +| `make run` | Run server locally (gRPC + MCP + web) | +| `make run-all` | Same as `make run` | | `make docker-build` | Build Docker image | | `make docker-run` | Run Docker container | | `make docker-compose-up` | Start with docker-compose | @@ -359,6 +360,7 @@ readinessProbe: |------|---------|-------------| | `-grpc-port` | 50051 | gRPC server listening port | | `-health-port` | 8080 | Health check HTTP server port | +| `-health-check` | false | Probe local `/health/ready` and exit 0/1 (container HEALTHCHECK) | ### Environment Variables diff --git a/internal/health/health.go b/internal/health/health.go index d810649..da4d8d7 100644 --- a/internal/health/health.go +++ b/internal/health/health.go @@ -19,9 +19,13 @@ package health import ( + "context" "encoding/json" + "fmt" "net/http" + "strings" "sync" + "time" ) // Checker implements liveness and readiness probes @@ -93,3 +97,23 @@ func (c *Checker) ReadinessHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } + +// Probe GETs origin/health/ready. Used by the process-local -health-check flag +// so Docker HEALTHCHECK does not need curl or wget in the image. +func Probe(ctx context.Context, origin string) error { + u := strings.TrimRight(origin, "/") + "/health/ready" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned %s", u, resp.Status) + } + return nil +} diff --git a/internal/health/health_test.go b/internal/health/health_test.go new file mode 100644 index 0000000..49d6d02 --- /dev/null +++ b/internal/health/health_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025 Index Exchange Inc. +// +// This file is part of the Agentic RTB Framework reference implementation. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package health + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestProbeReady(t *testing.T) { + checker := NewChecker() + checker.SetReady(true) + mux := http.NewServeMux() + mux.HandleFunc("/health/ready", checker.ReadinessHandler) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := Probe(ctx, srv.URL); err != nil { + t.Fatalf("Probe ready: %v", err) + } +} + +func TestProbeNotReady(t *testing.T) { + checker := NewChecker() + mux := http.NewServeMux() + mux.HandleFunc("/health/ready", checker.ReadinessHandler) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := Probe(ctx, srv.URL); err == nil { + t.Fatal("Probe expected error when not ready") + } +} + +func TestProbeUnreachable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := Probe(ctx, "http://127.0.0.1:1"); err == nil { + t.Fatal("Probe expected error for unreachable origin") + } +} diff --git a/rust/cargo.toml b/rust/Cargo.toml similarity index 100% rename from rust/cargo.toml rename to rust/Cargo.toml diff --git a/rust/Dockerfile b/rust/Dockerfile index 379d8da..a5077be 100644 --- a/rust/Dockerfile +++ b/rust/Dockerfile @@ -33,7 +33,7 @@ RUN mkdir -p ${BASE_DIR}/proto COPY proto/*.proto ${BASE_DIR}/proto/ COPY src/*.rs ./src/ -COPY cargo.toml ./Cargo.toml +COPY Cargo.toml Cargo.lock ./ RUN cargo install --path . RUN apt-get update && apt-get install -y protobuf-compiler diff --git a/scripts/generate.sh b/scripts/generate.sh index 2570cbf..2fe2d69 100755 --- a/scripts/generate.sh +++ b/scripts/generate.sh @@ -2,8 +2,8 @@ # Generate Go code from protobuf definitions # Requires: protoc, protoc-gen-go, protoc-gen-go-grpc # -# OpenRTB 2.6 proto is fetched from IAB Tech Lab repository: -# https://github.com/IABTechLab/openrtb-proto-v2 +# OpenRTB 2.6 proto is vendored at proto/com/iabtechlab/openrtb/v2/openrtb.proto +# (the source import path says v2.6/; the file lives under v2/). set -e @@ -12,13 +12,12 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" PROTO_DIR="$PROJECT_ROOT/proto" OUT_DIR="$PROJECT_ROOT/pkg/pb" -# OpenRTB proto location (downloaded by make fetch-openrtb) OPENRTB_PROTO="$PROTO_DIR/com/iabtechlab/openrtb/v2/openrtb.proto" # Check if OpenRTB proto exists if [ ! -f "$OPENRTB_PROTO" ]; then echo "Error: OpenRTB proto not found at $OPENRTB_PROTO" - echo "Run 'make fetch-openrtb' to download it from IAB Tech Lab repository" + echo "It is vendored at proto/com/iabtechlab/openrtb/v2/openrtb.proto" exit 1 fi