backbeat: add module sources

This commit is contained in:
anthonyrawlins
2025-10-17 08:56:25 +11:00
parent 627d15b3f7
commit 4b4eb16efb
48 changed files with 11636 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
# BACKBEAT Contracts CI Integration Makefile
# Variables
SCHEMA_DIR = ../../schemas
EXAMPLES_DIR = ../examples
CLI_TOOL = ./cmd/backbeat-validate
BINARY_NAME = backbeat-validate
# Default target
.PHONY: all
all: build test
# Build the CLI validation tool
.PHONY: build
build:
@echo "Building BACKBEAT validation CLI tool..."
go build -o $(BINARY_NAME) $(CLI_TOOL)
# Run all tests
.PHONY: test
test: test-schemas test-examples test-integration
# Test schema files are valid
.PHONY: test-schemas
test-schemas:
@echo "Testing JSON schema files..."
@for schema in $(SCHEMA_DIR)/*.schema.json; do \
echo "Validating schema: $$schema"; \
python3 -c "import json; json.load(open('$$schema'))" || exit 1; \
done
# Test all example files
.PHONY: test-examples
test-examples: build
@echo "Testing example messages..."
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --dir $(EXAMPLES_DIR)
# Run Go integration tests
.PHONY: test-integration
test-integration:
@echo "Running Go integration tests..."
go test -v ./...
# Validate built-in examples
.PHONY: validate-examples
validate-examples: build
@echo "Validating built-in examples..."
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --examples
# Validate a specific directory (for CI use)
.PHONY: validate-dir
validate-dir: build
@if [ -z "$(DIR)" ]; then \
echo "Usage: make validate-dir DIR=/path/to/messages"; \
exit 1; \
fi
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --dir $(DIR) --exit-code
# Validate a specific file (for CI use)
.PHONY: validate-file
validate-file: build
@if [ -z "$(FILE)" ]; then \
echo "Usage: make validate-file FILE=/path/to/message.json"; \
exit 1; \
fi
./$(BINARY_NAME) --schemas $(SCHEMA_DIR) --file $(FILE) --exit-code
# Clean build artifacts
.PHONY: clean
clean:
rm -f $(BINARY_NAME)
# Install dependencies
.PHONY: deps
deps:
go mod tidy
go mod download
# Format Go code
.PHONY: fmt
fmt:
go fmt ./...
# Run static analysis
.PHONY: lint
lint:
go vet ./...
# Generate CI configuration examples
.PHONY: examples
examples: generate-github-actions generate-gitlab-ci generate-makefile-example
# Generate GitHub Actions workflow
.PHONY: generate-github-actions
generate-github-actions:
@echo "Generating GitHub Actions workflow..."
@mkdir -p ci-examples
@cat > ci-examples/github-actions.yml << 'EOF'\
name: BACKBEAT Contract Validation\
\
on:\
push:\
branches: [ main, develop ]\
pull_request:\
branches: [ main ]\
\
jobs:\
validate-backbeat-messages:\
runs-on: ubuntu-latest\
\
steps:\
- uses: actions/checkout@v4\
with:\
repository: 'chorus-services/backbeat'\
path: 'backbeat-contracts'\
\
- uses: actions/checkout@v4\
with:\
path: 'current-repo'\
\
- name: Set up Go\
uses: actions/setup-go@v4\
with:\
go-version: '1.22'\
\
- name: Build BACKBEAT validator\
run: |\
cd backbeat-contracts/contracts/tests/integration\
make build\
\
- name: Validate BACKBEAT messages\
run: |\
cd backbeat-contracts/contracts/tests/integration\
./backbeat-validate \\\
--schemas ../../schemas \\\
--dir ../../../current-repo/path/to/messages \\\
--exit-code\
EOF
# Generate GitLab CI configuration
.PHONY: generate-gitlab-ci
generate-gitlab-ci:
@echo "Generating GitLab CI configuration..."
@mkdir -p ci-examples
@cat > ci-examples/gitlab-ci.yml << 'EOF'\
validate-backbeat-contracts:\
stage: test\
image: golang:1.22\
\
before_script:\
- git clone https://github.com/chorus-services/backbeat.git /tmp/backbeat\
- cd /tmp/backbeat/contracts/tests/integration\
- make deps build\
\
script:\
- /tmp/backbeat/contracts/tests/integration/backbeat-validate \\\
--schemas /tmp/backbeat/contracts/schemas \\\
--dir $$CI_PROJECT_DIR/path/to/messages \\\
--exit-code\
\
only:\
- merge_requests\
- main\
- develop\
EOF
# Generate example Makefile for downstream projects
.PHONY: generate-makefile-example
generate-makefile-example:
@echo "Generating example Makefile for downstream projects..."
@mkdir -p ci-examples
@echo "# Example Makefile for BACKBEAT contract validation" > ci-examples/downstream-makefile
@echo "" >> ci-examples/downstream-makefile
@echo "BACKBEAT_REPO = https://github.com/chorus-services/backbeat.git" >> ci-examples/downstream-makefile
@echo "BACKBEAT_DIR = .backbeat-contracts" >> ci-examples/downstream-makefile
@echo "" >> ci-examples/downstream-makefile
@echo "validate-backbeat:" >> ci-examples/downstream-makefile
@echo " git clone \$$(BACKBEAT_REPO) \$$(BACKBEAT_DIR) 2>/dev/null || true" >> ci-examples/downstream-makefile
@echo " cd \$$(BACKBEAT_DIR)/contracts/tests/integration && make build" >> ci-examples/downstream-makefile
@echo " \$$(BACKBEAT_DIR)/contracts/tests/integration/backbeat-validate --schemas \$$(BACKBEAT_DIR)/contracts/schemas --dir messages --exit-code" >> ci-examples/downstream-makefile
# Help target
.PHONY: help
help:
@echo "BACKBEAT Contracts CI Integration Makefile"
@echo ""
@echo "Available targets:"
@echo " all - Build and test everything"
@echo " build - Build the CLI validation tool"
@echo " test - Run all tests"
@echo " test-schemas - Validate JSON schema files"
@echo " test-examples - Test example message files"
@echo " test-integration - Run Go integration tests"
@echo " validate-examples - Validate built-in examples"
@echo " validate-dir DIR=path - Validate messages in directory"
@echo " validate-file FILE=path - Validate single message file"
@echo " clean - Clean build artifacts"
@echo " deps - Install Go dependencies"
@echo " fmt - Format Go code"
@echo " lint - Run static analysis"
@echo " examples - Generate CI configuration examples"
@echo " help - Show this help message"
@echo ""
@echo "Examples:"
@echo " make validate-dir DIR=../../../examples"
@echo " make validate-file FILE=../../../examples/beatframe-valid.json"

View File

@@ -0,0 +1,279 @@
// Package integration provides CI helper functions for BACKBEAT contract testing
package integration
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// CIHelper provides utilities for continuous integration testing
type CIHelper struct {
validator *MessageValidator
}
// NewCIHelper creates a new CI helper with a message validator
func NewCIHelper(schemaDir string) (*CIHelper, error) {
validator, err := NewMessageValidator(schemaDir)
if err != nil {
return nil, fmt.Errorf("failed to create validator: %w", err)
}
return &CIHelper{
validator: validator,
}, nil
}
// ValidateDirectory validates all JSON files in a directory against BACKBEAT schemas
func (ci *CIHelper) ValidateDirectory(dir string) (*DirectoryValidationResult, error) {
result := &DirectoryValidationResult{
Directory: dir,
Files: make(map[string]*FileValidationResult),
}
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip non-JSON files
if d.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".json") {
return nil
}
fileResult, validateErr := ci.validateFile(path)
if validateErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Failed to validate %s: %v", path, validateErr))
} else {
relPath, _ := filepath.Rel(dir, path)
result.Files[relPath] = fileResult
result.TotalFiles++
if fileResult.AllValid {
result.ValidFiles++
} else {
result.InvalidFiles++
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
result.ValidationRate = float64(result.ValidFiles) / float64(result.TotalFiles)
return result, nil
}
// validateFile validates a single JSON file
func (ci *CIHelper) validateFile(filePath string) (*FileValidationResult, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
result := &FileValidationResult{
FilePath: filePath,
AllValid: true,
}
// Try to parse as single message first
var singleMessage map[string]interface{}
if err := json.Unmarshal(data, &singleMessage); err == nil {
if msgType, hasType := singleMessage["type"].(string); hasType && ci.validator.IsMessageTypeSupported(msgType) {
// Single BACKBEAT message
validationResult, validateErr := ci.validator.ValidateMessage(data)
if validateErr != nil {
return nil, validateErr
}
result.Messages = []*ValidationResult{validationResult}
result.AllValid = validationResult.Valid
return result, nil
}
}
// Try to parse as array of messages
var messageArray []map[string]interface{}
if err := json.Unmarshal(data, &messageArray); err == nil {
for i, msg := range messageArray {
msgBytes, marshalErr := json.Marshal(msg)
if marshalErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Message %d: failed to marshal: %v", i, marshalErr))
result.AllValid = false
continue
}
validationResult, validateErr := ci.validator.ValidateMessage(msgBytes)
if validateErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Message %d: validation error: %v", i, validateErr))
result.AllValid = false
continue
}
result.Messages = append(result.Messages, validationResult)
if !validationResult.Valid {
result.AllValid = false
}
}
return result, nil
}
// Try to parse as examples format (array with description and message fields)
var examples []ExampleMessage
if err := json.Unmarshal(data, &examples); err == nil {
for i, example := range examples {
msgBytes, marshalErr := json.Marshal(example.Message)
if marshalErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Example %d (%s): failed to marshal: %v", i, example.Description, marshalErr))
result.AllValid = false
continue
}
validationResult, validateErr := ci.validator.ValidateMessage(msgBytes)
if validateErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Example %d (%s): validation error: %v", i, example.Description, validateErr))
result.AllValid = false
continue
}
result.Messages = append(result.Messages, validationResult)
if !validationResult.Valid {
result.AllValid = false
}
}
return result, nil
}
return nil, fmt.Errorf("file does not contain valid JSON message format")
}
// ExampleMessage represents a message example with description
type ExampleMessage struct {
Description string `json:"description"`
Message map[string]interface{} `json:"message"`
}
// DirectoryValidationResult contains results for validating a directory
type DirectoryValidationResult struct {
Directory string `json:"directory"`
TotalFiles int `json:"total_files"`
ValidFiles int `json:"valid_files"`
InvalidFiles int `json:"invalid_files"`
ValidationRate float64 `json:"validation_rate"`
Files map[string]*FileValidationResult `json:"files"`
Errors []string `json:"errors,omitempty"`
}
// FileValidationResult contains results for validating a single file
type FileValidationResult struct {
FilePath string `json:"file_path"`
AllValid bool `json:"all_valid"`
Messages []*ValidationResult `json:"messages"`
Errors []string `json:"errors,omitempty"`
}
// GenerateCIReport generates a formatted report suitable for CI systems
func (ci *CIHelper) GenerateCIReport(result *DirectoryValidationResult) string {
var sb strings.Builder
sb.WriteString("BACKBEAT Contract Validation Report\n")
sb.WriteString("===================================\n\n")
sb.WriteString(fmt.Sprintf("Directory: %s\n", result.Directory))
sb.WriteString(fmt.Sprintf("Total Files: %d\n", result.TotalFiles))
sb.WriteString(fmt.Sprintf("Valid Files: %d\n", result.ValidFiles))
sb.WriteString(fmt.Sprintf("Invalid Files: %d\n", result.InvalidFiles))
sb.WriteString(fmt.Sprintf("Validation Rate: %.2f%%\n\n", result.ValidationRate*100))
if len(result.Errors) > 0 {
sb.WriteString("Directory-level Errors:\n")
for _, err := range result.Errors {
sb.WriteString(fmt.Sprintf(" - %s\n", err))
}
sb.WriteString("\n")
}
// Group files by validation status
validFiles := make([]string, 0)
invalidFiles := make([]string, 0)
for filePath, fileResult := range result.Files {
if fileResult.AllValid {
validFiles = append(validFiles, filePath)
} else {
invalidFiles = append(invalidFiles, filePath)
}
}
if len(validFiles) > 0 {
sb.WriteString("Valid Files:\n")
for _, file := range validFiles {
sb.WriteString(fmt.Sprintf(" ✓ %s\n", file))
}
sb.WriteString("\n")
}
if len(invalidFiles) > 0 {
sb.WriteString("Invalid Files:\n")
for _, file := range invalidFiles {
fileResult := result.Files[file]
sb.WriteString(fmt.Sprintf(" ✗ %s\n", file))
for _, err := range fileResult.Errors {
sb.WriteString(fmt.Sprintf(" - %s\n", err))
}
for i, msg := range fileResult.Messages {
if !msg.Valid {
sb.WriteString(fmt.Sprintf(" Message %d (%s):\n", i+1, msg.MessageType))
for _, valErr := range msg.Errors {
sb.WriteString(fmt.Sprintf(" - %s: %s\n", valErr.Field, valErr.Message))
}
}
}
sb.WriteString("\n")
}
}
return sb.String()
}
// ExitWithStatus exits the program with appropriate status code for CI
func (ci *CIHelper) ExitWithStatus(result *DirectoryValidationResult) {
if result.InvalidFiles > 0 || len(result.Errors) > 0 {
fmt.Fprint(os.Stderr, ci.GenerateCIReport(result))
os.Exit(1)
} else {
fmt.Print(ci.GenerateCIReport(result))
os.Exit(0)
}
}
// ValidateExamples validates the built-in example messages
func (ci *CIHelper) ValidateExamples() ([]*ValidationResult, error) {
examples := ExampleMessages()
results := make([]*ValidationResult, 0, len(examples))
for name, example := range examples {
result, err := ci.validator.ValidateStruct(example)
if err != nil {
return nil, fmt.Errorf("failed to validate example %s: %w", name, err)
}
results = append(results, result)
}
return results, nil
}
// GetSchemaInfo returns information about loaded schemas
func (ci *CIHelper) GetSchemaInfo() map[string]string {
info := make(map[string]string)
for _, msgType := range ci.validator.GetSupportedMessageTypes() {
info[msgType] = getSchemaVersion(msgType)
}
return info
}

View File

@@ -0,0 +1,283 @@
// Package integration provides CI validation helpers for BACKBEAT conformance testing
package integration
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/xeipuuv/gojsonschema"
)
// MessageValidator provides validation for BACKBEAT messages against JSON schemas
type MessageValidator struct {
schemaLoaders map[string]gojsonschema.JSONLoader
}
// MessageType constants for the three core BACKBEAT interfaces
const (
BeatFrameType = "backbeat.beatframe.v1"
StatusClaimType = "backbeat.statusclaim.v1"
BarReportType = "backbeat.barreport.v1"
)
// ValidationError represents a validation failure with context
type ValidationError struct {
MessageType string `json:"message_type"`
Field string `json:"field"`
Value string `json:"value"`
Message string `json:"message"`
Errors []string `json:"errors"`
}
func (ve ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", ve.MessageType, strings.Join(ve.Errors, "; "))
}
// ValidationResult contains the outcome of message validation
type ValidationResult struct {
Valid bool `json:"valid"`
MessageType string `json:"message_type"`
Errors []ValidationError `json:"errors,omitempty"`
SchemaVersion string `json:"schema_version"`
}
// NewMessageValidator creates a new validator with schema loaders
func NewMessageValidator(schemaDir string) (*MessageValidator, error) {
validator := &MessageValidator{
schemaLoaders: make(map[string]gojsonschema.JSONLoader),
}
// Load all schema files
schemas := map[string]string{
BeatFrameType: "beatframe-v1.schema.json",
StatusClaimType: "statusclaim-v1.schema.json",
BarReportType: "barreport-v1.schema.json",
}
for msgType, schemaFile := range schemas {
schemaPath := filepath.Join(schemaDir, schemaFile)
loader := gojsonschema.NewReferenceLoader("file://" + schemaPath)
validator.schemaLoaders[msgType] = loader
}
return validator, nil
}
// ValidateMessage validates a JSON message against the appropriate BACKBEAT schema
func (v *MessageValidator) ValidateMessage(messageJSON []byte) (*ValidationResult, error) {
// Parse message to determine type
var msgMap map[string]interface{}
if err := json.Unmarshal(messageJSON, &msgMap); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
msgType, ok := msgMap["type"].(string)
if !ok {
return &ValidationResult{
Valid: false,
MessageType: "unknown",
Errors: []ValidationError{
{
Field: "type",
Message: "message type field is missing or not a string",
Errors: []string{"type field is required and must be a string"},
},
},
}, nil
}
// Get appropriate schema loader
schemaLoader, exists := v.schemaLoaders[msgType]
if !exists {
return &ValidationResult{
Valid: false,
MessageType: msgType,
Errors: []ValidationError{
{
Field: "type",
Value: msgType,
Message: fmt.Sprintf("unsupported message type: %s", msgType),
Errors: []string{fmt.Sprintf("message type %s is not supported by BACKBEAT contracts", msgType)},
},
},
}, nil
}
// Validate against schema
docLoader := gojsonschema.NewBytesLoader(messageJSON)
result, err := gojsonschema.Validate(schemaLoader, docLoader)
if err != nil {
return nil, fmt.Errorf("schema validation failed: %w", err)
}
validationResult := &ValidationResult{
Valid: result.Valid(),
MessageType: msgType,
SchemaVersion: getSchemaVersion(msgType),
}
if !result.Valid() {
for _, desc := range result.Errors() {
validationResult.Errors = append(validationResult.Errors, ValidationError{
MessageType: msgType,
Field: desc.Field(),
Value: fmt.Sprintf("%v", desc.Value()),
Message: desc.Description(),
Errors: []string{desc.String()},
})
}
}
return validationResult, nil
}
// ValidateMessageString validates a JSON message string
func (v *MessageValidator) ValidateMessageString(messageJSON string) (*ValidationResult, error) {
return v.ValidateMessage([]byte(messageJSON))
}
// ValidateStruct validates a Go struct by marshaling to JSON first
func (v *MessageValidator) ValidateStruct(message interface{}) (*ValidationResult, error) {
jsonBytes, err := json.Marshal(message)
if err != nil {
return nil, fmt.Errorf("failed to marshal struct to JSON: %w", err)
}
return v.ValidateMessage(jsonBytes)
}
// BatchValidate validates multiple messages and returns aggregated results
func (v *MessageValidator) BatchValidate(messages [][]byte) ([]*ValidationResult, error) {
results := make([]*ValidationResult, len(messages))
for i, msg := range messages {
result, err := v.ValidateMessage(msg)
if err != nil {
return nil, fmt.Errorf("failed to validate message %d: %w", i, err)
}
results[i] = result
}
return results, nil
}
// GetSupportedMessageTypes returns the list of supported BACKBEAT message types
func (v *MessageValidator) GetSupportedMessageTypes() []string {
types := make([]string, 0, len(v.schemaLoaders))
for msgType := range v.schemaLoaders {
types = append(types, msgType)
}
return types
}
// IsMessageTypeSupported checks if a message type is supported
func (v *MessageValidator) IsMessageTypeSupported(msgType string) bool {
_, exists := v.schemaLoaders[msgType]
return exists
}
// getSchemaVersion returns the version for a given message type
func getSchemaVersion(msgType string) string {
versions := map[string]string{
BeatFrameType: "1.0.0",
StatusClaimType: "1.0.0",
BarReportType: "1.0.0",
}
return versions[msgType]
}
// ValidationStats provides summary statistics for batch validation
type ValidationStats struct {
TotalMessages int `json:"total_messages"`
ValidMessages int `json:"valid_messages"`
InvalidMessages int `json:"invalid_messages"`
MessageTypes map[string]int `json:"message_types"`
ErrorSummary map[string]int `json:"error_summary"`
ValidationRate float64 `json:"validation_rate"`
}
// GetValidationStats computes statistics from validation results
func GetValidationStats(results []*ValidationResult) *ValidationStats {
stats := &ValidationStats{
TotalMessages: len(results),
MessageTypes: make(map[string]int),
ErrorSummary: make(map[string]int),
}
for _, result := range results {
// Count message types
stats.MessageTypes[result.MessageType]++
if result.Valid {
stats.ValidMessages++
} else {
stats.InvalidMessages++
// Aggregate error types
for _, err := range result.Errors {
stats.ErrorSummary[err.Field]++
}
}
}
if stats.TotalMessages > 0 {
stats.ValidationRate = float64(stats.ValidMessages) / float64(stats.TotalMessages)
}
return stats
}
// ExampleMessages provides sample messages for testing and documentation
func ExampleMessages() map[string]interface{} {
return map[string]interface{}{
"beatframe_minimal": map[string]interface{}{
"type": BeatFrameType,
"cluster_id": "test-cluster",
"beat_index": 0,
"downbeat": true,
"phase": "plan",
"hlc": "0001:0000:cafe",
"deadline_at": "2025-09-05T12:00:30Z",
"tempo_bpm": 2.0,
"window_id": "a1b2c3d4e5f6789012345678901234ab",
},
"statusclaim_minimal": map[string]interface{}{
"type": StatusClaimType,
"agent_id": "test:agent",
"beat_index": 100,
"state": "idle",
"hlc": "7ffd:0001:abcd",
},
"barreport_minimal": map[string]interface{}{
"type": BarReportType,
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
"from_beat": 0,
"to_beat": 119,
"agents_reporting": 1,
"on_time_reviews": 1,
"help_promises_fulfilled": 0,
"secret_rotations_ok": true,
"tempo_drift_ms": 0.0,
},
}
}
// PrettyPrintValidationResult formats validation results for human reading
func PrettyPrintValidationResult(result *ValidationResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Message Type: %s\n", result.MessageType))
sb.WriteString(fmt.Sprintf("Schema Version: %s\n", result.SchemaVersion))
sb.WriteString(fmt.Sprintf("Valid: %t\n", result.Valid))
if !result.Valid && len(result.Errors) > 0 {
sb.WriteString("\nValidation Errors:\n")
for i, err := range result.Errors {
sb.WriteString(fmt.Sprintf(" %d. Field: %s\n", i+1, err.Field))
if err.Value != "" {
sb.WriteString(fmt.Sprintf(" Value: %s\n", err.Value))
}
sb.WriteString(fmt.Sprintf(" Error: %s\n", err.Message))
}
}
return sb.String()
}