diff --git a/examples/kubernetes_deployment/README.md b/examples/kubernetes_deployment/README.md new file mode 100644 index 00000000..9483ca3c --- /dev/null +++ b/examples/kubernetes_deployment/README.md @@ -0,0 +1,379 @@ +# Kubernetes Deployment Example + +This example demonstrates how to use the Blueprint Kubernetes plugin to deploy a multi-service application to a Kubernetes cluster. + +## Overview + +The example includes three different wiring specifications: + +1. **WireSpec** - A complete e-commerce application with multiple services +2. **WireSpecWithNamespaces** - The same application organized using Blueprint namespaces +3. **SimpleExample** - A minimal "Hello World" service deployment + +## Architecture + +### E-Commerce Application (WireSpec) + +The example e-commerce application consists of: + +**Frontend Services:** +- `frontend` - Web UI served on port 3000 +- `api-gateway` - API Gateway on port 8080 routing requests to backend services + +**Backend Services:** +- `user-service` - User authentication and profiles (port 8081) +- `product-service` - Product catalog management (port 8082) +- `order-service` - Order processing (port 8083) +- `cart-service` - Shopping cart management (port 8084) +- `payment-service` - Payment processing with Stripe integration (port 8085) +- `notification-service` - Email and notification handling (port 8086) + +**Data Stores:** +- `mongodb` - Document database for products and orders +- `redis-cache` - In-memory cache for sessions and data + +**Observability:** +- `jaeger` - Distributed tracing for all services +- Health checking endpoints for all services + +## Prerequisites + +1. A running Kubernetes cluster +2. `kubectl` configured to access your cluster +3. Blueprint framework installed +4. Go 1.19 or later + +## Building the Application + +### Option 1: Simple Example + +```bash +# Compile the simple example +blueprint compile \ + -w examples/kubernetes_deployment/wiring/main.go \ + -f SimpleExample \ + -o output/simple +``` + +### Option 2: Full E-Commerce Application + +```bash +# Compile the full application +blueprint compile \ + -w examples/kubernetes_deployment/wiring/main.go \ + -f WireSpec \ + -o output/ecommerce +``` + +### Option 3: Namespaced Organization + +```bash +# Compile with namespace organization +blueprint compile \ + -w examples/kubernetes_deployment/wiring/main.go \ + -f WireSpecWithNamespaces \ + -o output/ecommerce-namespaced +``` + +## Deployment + +After compilation, navigate to the output directory and deploy: + +### 1. Review Generated Manifests + +```bash +cd output/ecommerce/kubernetes/ +ls -la + +# You should see: +# - deployment.yaml # Kubernetes Deployment +# - services.yaml # Service definitions +# - configmap.yaml # Environment variables +# - namespace.yaml # Namespace definition +# - deploy.sh # Deployment script +# - README.md # Deployment instructions +``` + +### 2. Configure Cluster Access + +```bash +# Ensure kubectl is configured +kubectl config current-context + +# Verify cluster access +kubectl cluster-info +``` + +### 3. Deploy to Kubernetes + +```bash +# Using the generated script +./deploy.sh + +# Or manually +kubectl apply -f namespace.yaml +kubectl apply -f configmap.yaml +kubectl apply -f services.yaml +kubectl apply -f deployment.yaml +``` + +### 4. Verify Deployment + +```bash +# Check deployment status +kubectl get deployments -n ecommerce + +# Check all pods are running +kubectl get pods -n ecommerce + +# Check services +kubectl get services -n ecommerce +``` + +## Accessing the Application + +### Port Forwarding (Development) + +```bash +# Forward frontend service +kubectl port-forward -n ecommerce service/frontend-service 3000:3000 + +# Forward API gateway +kubectl port-forward -n ecommerce service/api-gateway-service 8080:8080 + +# Access Jaeger UI +kubectl port-forward -n ecommerce service/jaeger-service 16686:16686 +``` + +Then access: +- Frontend: http://localhost:3000 +- API Gateway: http://localhost:8080 +- Jaeger UI: http://localhost:16686 + +### Production Access + +For production, you would typically: + +1. Configure an Ingress controller +2. Set up LoadBalancer services +3. Configure DNS entries + +Example Ingress configuration: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ecommerce-ingress + namespace: ecommerce +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: frontend-service + port: + number: 3000 + - host: api.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-gateway-service + port: + number: 8080 +``` + +## Configuration + +### Environment Variables + +The application uses environment variables for configuration. These are stored in a ConfigMap and can be modified: + +```bash +# Edit the ConfigMap +kubectl edit configmap ecommerce-app-config -n ecommerce +``` + +Key environment variables: +- `STRIPE_API_KEY` - Payment processor API key +- `SMTP_HOST`, `SMTP_PORT` - Email server configuration +- `NODE_ENV` - Application environment (development/production) + +### Scaling + +Adjust the number of replicas: + +```bash +# Scale the deployment +kubectl scale deployment ecommerce-app -n ecommerce --replicas=5 + +# Or edit the deployment +kubectl edit deployment ecommerce-app -n ecommerce +``` + +## Monitoring + +### View Logs + +```bash +# View logs for a specific pod +kubectl logs -n ecommerce + +# Follow logs +kubectl logs -f -n ecommerce + +# View logs for all pods with a label +kubectl logs -n ecommerce -l app=ecommerce-app +``` + +### Health Checks + +All services expose health check endpoints: + +```bash +# Check health of a service +kubectl exec -n ecommerce -- curl http://localhost:8080/health +``` + +### Distributed Tracing + +Access Jaeger UI to view traces: + +```bash +kubectl port-forward -n ecommerce service/jaeger-service 16686:16686 +``` + +Open http://localhost:16686 in your browser. + +## Troubleshooting + +### Pods Not Starting + +```bash +# Describe the pod for details +kubectl describe pod -n ecommerce + +# Check events +kubectl get events -n ecommerce --sort-by='.lastTimestamp' +``` + +### Service Discovery Issues + +```bash +# Test DNS resolution from within a pod +kubectl exec -n ecommerce -- nslookup user-service + +# Test service connectivity +kubectl exec -n ecommerce -- curl http://user-service:8081/health +``` + +### Database Connection Issues + +```bash +# Check MongoDB pod +kubectl logs -n ecommerce -l app=mongodb + +# Check Redis pod +kubectl logs -n ecommerce -l app=redis-cache +``` + +## Cleanup + +To remove the deployed application: + +```bash +# Delete all resources in the namespace +kubectl delete namespace ecommerce + +# Or delete individual resources +kubectl delete -f deployment.yaml +kubectl delete -f services.yaml +kubectl delete -f configmap.yaml +kubectl delete -f namespace.yaml +``` + +## Customization + +### Adding New Services + +1. Edit the wiring specification to add new service definitions +2. Configure service dependencies and connections +3. Recompile and redeploy + +### Modifying Deployment Parameters + +Edit the wiring specification to change: +- Namespace: `kubernetes.SetNamespace(deployment, "custom-namespace")` +- Replicas: `kubernetes.SetReplicas(deployment, 5)` +- Cluster config: `kubernetes.ConfigureCluster(deployment, endpoint, kubeconfig, token)` + +## Advanced Features + +### Using with CI/CD + +The generated artifacts can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +name: Deploy to Kubernetes +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Compile Blueprint + run: | + blueprint compile \ + -w examples/kubernetes_deployment/wiring/main.go \ + -f WireSpec \ + -o output + - name: Deploy to Kubernetes + run: | + kubectl apply -f output/kubernetes/namespace.yaml + kubectl apply -f output/kubernetes/configmap.yaml + kubectl apply -f output/kubernetes/services.yaml + kubectl apply -f output/kubernetes/deployment.yaml +``` + +### Multi-Environment Deployments + +Use different wiring functions for different environments: + +```go +// development.go +func WireSpecDev(spec wiring.WiringSpec) { + // Development configuration + kubernetes.SetReplicas(deployment, 1) + kubernetes.SetNamespace(deployment, "dev") +} + +// production.go +func WireSpecProd(spec wiring.WiringSpec) { + // Production configuration + kubernetes.SetReplicas(deployment, 5) + kubernetes.SetNamespace(deployment, "prod") +} +``` + +## Related Examples + +- `examples/sockshop` - Another microservices example +- `examples/dsb_hotel` - Hotel reservation system +- `examples/train_ticket` - Train ticket booking system + +## Support + +For issues or questions about this example: +1. Check the main Blueprint documentation +2. Review the Kubernetes plugin README at `plugins/kubernetes/README.md` +3. Open an issue on the Blueprint GitHub repository diff --git a/examples/kubernetes_deployment/wiring/main.go b/examples/kubernetes_deployment/wiring/main.go new file mode 100644 index 00000000..87e98144 --- /dev/null +++ b/examples/kubernetes_deployment/wiring/main.go @@ -0,0 +1,304 @@ +// Package main provides an example wiring specification for deploying +// a multi-service application to Kubernetes using the Blueprint Kubernetes plugin. +package main + +import ( + "github.com/blueprint-uservices/blueprint/blueprint/pkg/wiring" + "github.com/blueprint-uservices/blueprint/plugins/golang" + "github.com/blueprint-uservices/blueprint/plugins/grpc" + "github.com/blueprint-uservices/blueprint/plugins/healthchecker" + "github.com/blueprint-uservices/blueprint/plugins/http" + "github.com/blueprint-uservices/blueprint/plugins/jaeger" + "github.com/blueprint-uservices/blueprint/plugins/kubernetes" + "github.com/blueprint-uservices/blueprint/plugins/mongodb" + "github.com/blueprint-uservices/blueprint/plugins/redis" +) + +// WireSpec defines the wiring specification for deploying a sample +// e-commerce application to Kubernetes. +func WireSpec(spec wiring.WiringSpec) { + // Define the services + + // Frontend service - serves the web UI + frontend := golang.Service("frontend", + golang.WithServicePort(3000), + golang.WithEnvironment(map[string]string{ + "NODE_ENV": "production", + "API_URL": "http://api-gateway:8080", + }), + ) + + // API Gateway - routes requests to backend services + apiGateway := golang.Service("api-gateway", + golang.WithServicePort(8080), + ) + + // User Service - handles user authentication and profiles + userService := golang.Service("user-service", + golang.WithServicePort(8081), + ) + + // Product Service - manages product catalog + productService := golang.Service("product-service", + golang.WithServicePort(8082), + ) + + // Order Service - processes orders + orderService := golang.Service("order-service", + golang.WithServicePort(8083), + ) + + // Cart Service - manages shopping carts + cartService := golang.Service("cart-service", + golang.WithServicePort(8084), + ) + + // Payment Service - handles payment processing + paymentService := golang.Service("payment-service", + golang.WithServicePort(8085), + golang.WithEnvironment(map[string]string{ + "STRIPE_API_KEY": "${STRIPE_API_KEY}", + "PAYMENT_MODE": "sandbox", + }), + ) + + // Notification Service - sends emails and notifications + notificationService := golang.Service("notification-service", + golang.WithServicePort(8086), + golang.WithEnvironment(map[string]string{ + "SMTP_HOST": "smtp.gmail.com", + "SMTP_PORT": "587", + "EMAIL_FROM": "noreply@example.com", + }), + ) + + // Add databases + + // MongoDB for product catalog and orders + mongoDb := mongodb.Container("mongodb") + mongodb.Connect(productService, mongoDb, "products_db") + mongodb.Connect(orderService, mongoDb, "orders_db") + + // Redis for session storage and caching + redisCache := redis.Container("redis-cache") + redis.Connect(apiGateway, redisCache) + redis.Connect(cartService, redisCache) + redis.Connect(userService, redisCache) + + // Add HTTP endpoints + http.Expose(frontend, "frontend") + http.Expose(apiGateway, "api") + + // Add gRPC communication between services + grpc.Deploy(userService) + grpc.Deploy(productService) + grpc.Deploy(orderService) + grpc.Deploy(cartService) + grpc.Deploy(paymentService) + grpc.Deploy(notificationService) + + // Connect API Gateway to backend services + userClient := grpc.Client(apiGateway, userService) + productClient := grpc.Client(apiGateway, productService) + orderClient := grpc.Client(apiGateway, orderService) + cartClient := grpc.Client(apiGateway, cartService) + paymentClient := grpc.Client(apiGateway, paymentService) + notificationClient := grpc.Client(apiGateway, notificationService) + + // Add health checking + healthchecker.AddHealthCheck(frontend) + healthchecker.AddHealthCheck(apiGateway) + healthchecker.AddHealthCheck(userService) + healthchecker.AddHealthCheck(productService) + healthchecker.AddHealthCheck(orderService) + healthchecker.AddHealthCheck(cartService) + healthchecker.AddHealthCheck(paymentService) + healthchecker.AddHealthCheck(notificationService) + + // Add distributed tracing with Jaeger + jaegerCollector := jaeger.Collector("jaeger") + jaeger.Instrument(frontend, jaegerCollector) + jaeger.Instrument(apiGateway, jaegerCollector) + jaeger.Instrument(userService, jaegerCollector) + jaeger.Instrument(productService, jaegerCollector) + jaeger.Instrument(orderService, jaegerCollector) + jaeger.Instrument(cartService, jaegerCollector) + jaeger.Instrument(paymentService, jaegerCollector) + jaeger.Instrument(notificationService, jaegerCollector) + + // Create Kubernetes deployment + k8sDeployment := kubernetes.NewDeployment("ecommerce-app") + + // Add all services to the deployment + kubernetes.AddContainerToDeployment(k8sDeployment, frontend) + kubernetes.AddContainerToDeployment(k8sDeployment, apiGateway) + kubernetes.AddContainerToDeployment(k8sDeployment, userService) + kubernetes.AddContainerToDeployment(k8sDeployment, productService) + kubernetes.AddContainerToDeployment(k8sDeployment, orderService) + kubernetes.AddContainerToDeployment(k8sDeployment, cartService) + kubernetes.AddContainerToDeployment(k8sDeployment, paymentService) + kubernetes.AddContainerToDeployment(k8sDeployment, notificationService) + kubernetes.AddContainerToDeployment(k8sDeployment, mongoDb) + kubernetes.AddContainerToDeployment(k8sDeployment, redisCache) + kubernetes.AddContainerToDeployment(k8sDeployment, jaegerCollector) + + // Configure the Kubernetes deployment + kubernetes.SetNamespace(k8sDeployment, "ecommerce") + kubernetes.SetReplicas(k8sDeployment, 2) // 2 replicas for each service + + // Cluster configuration can be provided at runtime + // For now, we'll leave it empty to be configured during deployment + kubernetes.ConfigureCluster(k8sDeployment, "", "", "") + + // Add the deployment to the wiring spec + spec.AddNode(k8sDeployment) +} + +// WireSpecWithNamespaces demonstrates using namespace handlers +// to organize the deployment. +func WireSpecWithNamespaces(spec wiring.WiringSpec) { + // Define a Kubernetes namespace for the application + k8s := spec.Define("kubernetes", "ecommerce-app", func(ns wiring.Namespace) { + // The namespace will handle docker.Container nodes + // and deploy them to Kubernetes + }) + + // Define services in separate namespaces for organization + + // Frontend namespace + web := spec.Define("web", "frontend", func(ns wiring.Namespace) { + frontend := golang.Service("frontend", + golang.WithServicePort(3000), + ) + http.Expose(frontend, "frontend") + healthchecker.AddHealthCheck(frontend) + ns.Export(frontend, "frontend") + }) + + // API namespace + api := spec.Define("api", "gateway", func(ns wiring.Namespace) { + gateway := golang.Service("api-gateway", + golang.WithServicePort(8080), + ) + http.Expose(gateway, "api") + healthchecker.AddHealthCheck(gateway) + ns.Export(gateway, "gateway") + }) + + // Services namespace + services := spec.Define("services", "backend", func(ns wiring.Namespace) { + // Define all backend services + user := golang.Service("user-service", golang.WithServicePort(8081)) + product := golang.Service("product-service", golang.WithServicePort(8082)) + order := golang.Service("order-service", golang.WithServicePort(8083)) + cart := golang.Service("cart-service", golang.WithServicePort(8084)) + payment := golang.Service("payment-service", golang.WithServicePort(8085)) + notification := golang.Service("notification-service", golang.WithServicePort(8086)) + + // Deploy as gRPC services + grpc.Deploy(user) + grpc.Deploy(product) + grpc.Deploy(order) + grpc.Deploy(cart) + grpc.Deploy(payment) + grpc.Deploy(notification) + + // Add health checks + healthchecker.AddHealthCheck(user) + healthchecker.AddHealthCheck(product) + healthchecker.AddHealthCheck(order) + healthchecker.AddHealthCheck(cart) + healthchecker.AddHealthCheck(payment) + healthchecker.AddHealthCheck(notification) + + // Export services + ns.Export(user, "user") + ns.Export(product, "product") + ns.Export(order, "order") + ns.Export(cart, "cart") + ns.Export(payment, "payment") + ns.Export(notification, "notification") + }) + + // Data namespace + data := spec.Define("data", "storage", func(ns wiring.Namespace) { + mongo := mongodb.Container("mongodb") + redis := redis.Container("redis-cache") + + ns.Export(mongo, "mongodb") + ns.Export(redis, "redis") + }) + + // Observability namespace + observability := spec.Define("observability", "monitoring", func(ns wiring.Namespace) { + jaeger := jaeger.Collector("jaeger") + ns.Export(jaeger, "jaeger") + }) + + // Import services into the API gateway namespace + api.Import(services, "user", "userService") + api.Import(services, "product", "productService") + api.Import(services, "order", "orderService") + api.Import(services, "cart", "cartService") + api.Import(services, "payment", "paymentService") + api.Import(services, "notification", "notificationService") + + // Import data services + services.Import(data, "mongodb", "database") + services.Import(data, "redis", "cache") + api.Import(data, "redis", "sessionStore") + + // Import observability + web.Import(observability, "jaeger", "tracer") + api.Import(observability, "jaeger", "tracer") + services.Import(observability, "jaeger", "tracer") + + // Place all components in the Kubernetes namespace + k8s.Place(web.IR()) + k8s.Place(api.IR()) + k8s.Place(services.IR()) + k8s.Place(data.IR()) + k8s.Place(observability.IR()) + + // Configure Kubernetes deployment + deployment := k8s.IR() + kubernetes.SetNamespace(deployment, "ecommerce") + kubernetes.SetReplicas(deployment, 3) + + // Cluster config to be provided at runtime + kubernetes.ConfigureCluster(deployment, "", "", "") +} + +// SimpleExample provides a minimal example of deploying a single service +func SimpleExample(spec wiring.WiringSpec) { + // Create a simple HTTP service + helloService := golang.Service("hello-world", + golang.WithServicePort(8080), + golang.WithEnvironment(map[string]string{ + "MESSAGE": "Hello from Kubernetes!", + }), + ) + + // Expose HTTP endpoint + http.Expose(helloService, "hello") + + // Add health check + healthchecker.AddHealthCheck(helloService) + + // Create Kubernetes deployment + deployment := kubernetes.NewDeployment("hello-app") + kubernetes.AddContainerToDeployment(deployment, helloService) + + // Configure deployment + kubernetes.SetNamespace(deployment, "default") + kubernetes.SetReplicas(deployment, 1) + + // Add to spec + spec.AddNode(deployment) +} + +func main() { + // This main function is required for the wiring spec to compile + // The actual wiring function (WireSpec, WireSpecWithNamespaces, or SimpleExample) + // will be selected when running the Blueprint compiler +} diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md new file mode 100644 index 00000000..d99572e6 --- /dev/null +++ b/plugins/kubernetes/README.md @@ -0,0 +1,311 @@ +# Kubernetes Plugin + +The Kubernetes plugin enables deployment of Blueprint applications to pre-existing Kubernetes clusters. It generates Kubernetes manifests (Deployments, Services, ConfigMaps) and deployment scripts from Blueprint IR nodes. + +## Overview + +This plugin provides functionality to: +- Deploy containers to Kubernetes clusters +- Generate Kubernetes manifests (Deployments, Services, ConfigMaps) +- Handle service discovery through Kubernetes Services +- Manage environment variables via ConfigMaps +- Support runtime cluster configuration +- Generate cross-platform deployment scripts + +## Features + +### Container Deployment +- Transforms docker.Container nodes into Kubernetes Deployments +- Supports custom replica counts +- Handles container images and resource requirements + +### Service Discovery +- Automatically creates Kubernetes Services for containers with exposed ports +- Enables inter-service communication through cluster DNS +- Maps container ports to service ports + +### Configuration Management +- Environment variables stored in ConfigMaps +- Runtime cluster configuration (endpoint, credentials) +- Namespace isolation support + +### Deployment Artifacts +- Kubernetes YAML manifests +- Bash scripts for Linux/macOS deployment +- Batch scripts for Windows deployment +- Comprehensive deployment documentation + +## Usage + +### Basic Deployment + +```go +package main + +import ( + "github.com/blueprint-uservices/blueprint/plugins/kubernetes" + "github.com/blueprint-uservices/blueprint/plugins/golang" +) + +func main() { + // Define your service + backend := golang.Service("backend") + + // Create a Kubernetes deployment + deployment := kubernetes.NewDeployment("my-app") + + // Add the service container to the deployment + kubernetes.AddContainerToDeployment(deployment, backend) + + // Configure cluster (runtime configuration) + kubernetes.ConfigureCluster(deployment, + "https://k8s.example.com:6443", // API endpoint + "/path/to/kubeconfig", // Kubeconfig path + "my-auth-token") // Optional auth token + + // Set namespace + kubernetes.SetNamespace(deployment, "production") + + // Set replica count + kubernetes.SetReplicas(deployment, 3) +} +``` + +### Multi-Service Application + +```go +func DeployMicroservices(spec wiring.WiringSpec) { + // Define services + frontend := golang.Service("frontend") + backend := golang.Service("backend") + database := golang.Service("database") + + // Create deployment + deployment := kubernetes.NewDeployment("microservices") + + // Add all services + kubernetes.AddContainerToDeployment(deployment, frontend) + kubernetes.AddContainerToDeployment(deployment, backend) + kubernetes.AddContainerToDeployment(deployment, database) + + // Configure deployment + kubernetes.SetNamespace(deployment, "microservices") + kubernetes.SetReplicas(deployment, 2) + + // Runtime cluster config will be provided during deployment + kubernetes.ConfigureCluster(deployment, "", "", "") +} +``` + +### With Environment Variables + +```go +func DeployWithConfig(spec wiring.WiringSpec) { + service := golang.Service("my-service") + + // Set environment variables on the service + service.SetEnvironmentVariable("DATABASE_URL", "postgres://...") + service.SetEnvironmentVariable("API_KEY", "secret-key") + + // Deploy to Kubernetes + deployment := kubernetes.NewDeployment("configured-app") + kubernetes.AddContainerToDeployment(deployment, service) + + // Environment variables will be stored in a ConfigMap +} +``` + +## Wiring Specification + +The plugin can be used in wiring specifications with namespace handlers: + +```go +func WireApplication(spec wiring.WiringSpec) { + // Define a Kubernetes deployment namespace + k8s := spec.Define("kubernetes", "my-app", func(ns wiring.Namespace) { + // The namespace handler accepts docker.Container nodes + // and deploys them to Kubernetes + }) + + // Place services in the Kubernetes namespace + frontend := golang.Service("frontend") + backend := golang.Service("backend") + + k8s.Place(frontend) + k8s.Place(backend) + + // Configure the deployment + kubernetes.SetNamespace(k8s.IR(), "production") + kubernetes.SetReplicas(k8s.IR(), 3) +} +``` + +## Generated Artifacts + +When compiled, the plugin generates the following artifacts in the output directory: + +### Kubernetes Manifests (`kubernetes/`) +- `deployment.yaml` - Kubernetes Deployment resource +- `services.yaml` - Kubernetes Service resources for exposed ports +- `configmap.yaml` - ConfigMap for environment variables +- `namespace.yaml` - Namespace definition (if specified) + +### Deployment Scripts +- `deploy.sh` - Bash script for Linux/macOS deployment +- `deploy.bat` - Batch script for Windows deployment + +### Documentation +- `README.md` - Deployment instructions and service information + +## Deployment Process + +### 1. Generate Artifacts +```bash +# Compile your wiring specification +blueprint compile -w wiring/main.go -o output/ +``` + +### 2. Configure Cluster Access +```bash +# Set your kubeconfig +export KUBECONFIG=/path/to/kubeconfig + +# Or use kubectl config +kubectl config use-context my-cluster +``` + +### 3. Deploy to Kubernetes +```bash +# Navigate to output directory +cd output/kubernetes/ + +# Deploy using the generated script +./deploy.sh + +# Or apply manually +kubectl apply -f namespace.yaml +kubectl apply -f configmap.yaml +kubectl apply -f services.yaml +kubectl apply -f deployment.yaml +``` + +### 4. Verify Deployment +```bash +# Check deployment status +kubectl get deployments -n + +# Check pods +kubectl get pods -n + +# Check services +kubectl get services -n +``` + +## Configuration Options + +### Namespace Configuration +- `SetNamespace(deployment, namespace)` - Set Kubernetes namespace +- Default: "default" + +### Replica Configuration +- `SetReplicas(deployment, count)` - Set number of replicas +- Default: 1 + +### Cluster Configuration +- `ConfigureCluster(deployment, endpoint, kubeconfig, token)` +- Can be configured at compile time or runtime +- Supports multiple authentication methods + +## Service Discovery + +Services within the same deployment can discover each other using Kubernetes DNS: + +- Service URL format: `http://:` +- Full DNS name: `..svc.cluster.local` + +Example: +```go +// In your application code +backendURL := "http://backend-service:8080" +``` + +## Environment Variables + +Environment variables are automatically collected from container nodes and stored in a ConfigMap: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-app-config +data: + DATABASE_URL: "postgres://..." + API_KEY: "secret-key" +``` + +## Limitations + +- Requires a pre-existing Kubernetes cluster +- Does not provision cloud resources +- Persistent volumes must be manually configured +- Secrets should be managed separately for production use + +## Integration with Other Plugins + +The Kubernetes plugin works seamlessly with other Blueprint plugins: + +- **Docker Plugin**: Accepts docker.Container nodes +- **Golang Plugin**: Deploy Go services +- **HTTP/gRPC Plugins**: Expose services with proper ports +- **Jaeger/Zipkin**: Deploy tracing infrastructure + +## Examples + +See the `examples/` directory for complete examples: +- Simple web application deployment +- Microservices with service mesh +- Database-backed application +- Distributed tracing setup + +## Troubleshooting + +### Common Issues + +1. **Authentication Failed** + - Verify kubeconfig path + - Check cluster endpoint URL + - Ensure credentials are valid + +2. **Services Not Connecting** + - Check service names match + - Verify namespace configuration + - Ensure ports are correctly exposed + +3. **Pods Not Starting** + - Check image availability + - Review resource limits + - Examine pod logs: `kubectl logs -n ` + +### Debug Commands + +```bash +# Describe deployment +kubectl describe deployment -n + +# Get events +kubectl get events -n + +# View logs +kubectl logs -f -n + +# Port forward for testing +kubectl port-forward 8080:8080 -n +``` + +## Contributing + +Contributions are welcome! Please see the main Blueprint contributing guidelines. + +## License + +See the Blueprint project license. diff --git a/plugins/kubernetes/deploy.go b/plugins/kubernetes/deploy.go new file mode 100644 index 00000000..fe9b6b51 --- /dev/null +++ b/plugins/kubernetes/deploy.go @@ -0,0 +1,193 @@ +package kubernetes + +import ( + "fmt" + "path/filepath" + "reflect" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint/ioutil" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/address" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/blueprint-uservices/blueprint/plugins/kubernetes/kubernetesgen" + "golang.org/x/exp/slog" +) + +type ( + // kubernetesDeployer is the deployer interface for Kubernetes deployments + kubernetesDeployer interface { + ir.ArtifactGenerator + } + + // kubernetesWorkspace is a workspace used when deploying a set of containers + // to a Kubernetes cluster. It implements docker.ContainerWorkspace. + kubernetesWorkspace struct { + ir.VisitTrackerImpl + + info docker.ContainerWorkspaceInfo + + ImageDirs map[string]string // map from image name to directory + InstanceArgs map[string][]ir.IRNode // argnodes for each instance added to the workspace + + ManifestBuilder *kubernetesgen.ManifestBuilder + Deployment *KubernetesDeployment + } +) + +// Implements ir.ArtifactGenerator +func (node *KubernetesDeployment) GenerateArtifacts(dir string) error { + slog.Info(fmt.Sprintf("Collecting container instances for Kubernetes deployment %s in %s", node.Name(), dir)) + workspace := NewKubernetesWorkspace(node, dir) + return node.generateArtifacts(workspace) +} + +// The basic build process of a Kubernetes deployment +func (node *KubernetesDeployment) generateArtifacts(workspace *kubernetesWorkspace) error { + // Add any locally-built container images + for _, containerNode := range ir.Filter[docker.ProvidesContainerImage](node.Nodes) { + if err := containerNode.AddContainerArtifacts(workspace); err != nil { + return err + } + } + + // Collect all container instances + for _, containerNode := range ir.Filter[docker.ProvidesContainerInstance](node.Nodes) { + if err := containerNode.AddContainerInstance(workspace); err != nil { + return err + } + } + + // Build the Kubernetes manifests + if err := workspace.Finish(); err != nil { + return err + } + + return nil +} + +func NewKubernetesWorkspace(deployment *KubernetesDeployment, dir string) *kubernetesWorkspace { + return &kubernetesWorkspace{ + info: docker.ContainerWorkspaceInfo{ + Path: filepath.Clean(dir), + Target: "kubernetes", + }, + ImageDirs: make(map[string]string), + InstanceArgs: make(map[string][]ir.IRNode), + ManifestBuilder: kubernetesgen.NewManifestBuilder( + deployment.DeploymentName, + deployment.Namespace, + deployment.Replicas, + dir, + ), + Deployment: deployment, + } +} + +// Implements docker.ContainerWorkspace +func (w *kubernetesWorkspace) Info() docker.ContainerWorkspaceInfo { + return w.info +} + +// Implements docker.ContainerWorkspace +func (w *kubernetesWorkspace) CreateImageDir(imageName string) (string, error) { + // Only alphanumeric and underscores are allowed in an image name + imageName = ir.CleanName(imageName) + imageDir, err := ioutil.CreateNodeDir(w.info.Path, imageName) + w.ImageDirs[imageName] = imageDir + return imageDir, err +} + +// Implements docker.ContainerWorkspace +func (w *kubernetesWorkspace) DeclarePrebuiltInstance(instanceName string, image string, args ...ir.IRNode) error { + w.InstanceArgs[instanceName] = args + return w.ManifestBuilder.AddContainer(instanceName, image, false) +} + +// Implements docker.ContainerWorkspace +func (w *kubernetesWorkspace) DeclareLocalImage(instanceName string, imageDir string, args ...ir.IRNode) error { + w.InstanceArgs[instanceName] = args + // For local images, we'll need to build and push them to a registry + // For now, we'll use the imageDir as the image name + return w.ManifestBuilder.AddContainer(instanceName, imageDir, true) +} + +// Implements docker.ContainerWorkspace +func (w *kubernetesWorkspace) SetEnvironmentVariable(instanceName string, key string, val string) error { + return w.ManifestBuilder.AddEnvVar(instanceName, key, val) +} + +// Generates the Kubernetes manifests +func (w *kubernetesWorkspace) Finish() error { + // Process arg nodes for environment variables and networking + if err := w.processArgNodes(); err != nil { + return err + } + + // Generate all Kubernetes manifests + return w.ManifestBuilder.Generate() +} + +// processArgNodes processes each container's arg nodes, determining which need to be passed +// as environment variables, and handling service discovery through Kubernetes Services. +func (w *kubernetesWorkspace) processArgNodes() error { + addresses := make(map[string]string) + + for instanceName, instanceArgs := range w.InstanceArgs { + binds, dials, remaining := address.Split(instanceArgs) + + // Handle non-address arguments (config nodes) + for _, arg := range remaining { + switch node := arg.(type) { + case ir.IRConfig: + if !node.HasValue() { + // Pass through as environment variable + w.ManifestBuilder.PassthroughEnvVar(instanceName, node.Name(), node.Optional()) + } + default: + return blueprint.Errorf("container instance %v can only accept IRConfig nodes as arguments, but found %v of type %v", + instanceName, arg, reflect.TypeOf(arg)) + } + } + + // Assign ports and create services for bound addresses + _, assigned, err := address.AssignPorts(binds) + if err != nil { + return err + } + + // Add assigned ports as environment variables and create Kubernetes services + for _, bind := range assigned { + w.ManifestBuilder.AddEnvVar(instanceName, bind.Name(), fmt.Sprintf("0.0.0.0:%v", bind.Port)) + } + + // Create Kubernetes services for all bound ports + for _, bind := range binds { + serviceName := ir.CleanName(instanceName) + // In Kubernetes, services are accessed by their DNS name + addresses[bind.AddressName] = fmt.Sprintf("%v:%v", serviceName, bind.Port) + w.ManifestBuilder.ExposePort(instanceName, bind.Port, bind.Name()) + } + + address.Clear(binds) + } + + // Set dial addresses for inter-service communication + for instanceName, instanceArgs := range w.InstanceArgs { + _, dials, _ := address.Split(instanceArgs) + for _, dial := range dials { + if addr, isLocalDial := addresses[dial.AddressName]; isLocalDial { + // Use Kubernetes service DNS name for local services + w.ManifestBuilder.AddEnvVar(instanceName, dial.Name(), addr) + } else { + // External service - pass through as environment variable + w.ManifestBuilder.PassthroughEnvVar(instanceName, dial.Name(), false) + } + } + } + + return nil +} + +func (w *kubernetesWorkspace) ImplementsBuildContext() {} +func (w *kubernetesWorkspace) ImplementsContainerWorkspace() {} diff --git a/plugins/kubernetes/deploy_test.go b/plugins/kubernetes/deploy_test.go new file mode 100644 index 00000000..f7dedf97 --- /dev/null +++ b/plugins/kubernetes/deploy_test.go @@ -0,0 +1,494 @@ +package kubernetes + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/address" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/pointer" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/blueprint-uservices/blueprint/plugins/golang" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// mockBuildContext implements ir.BuildContext for testing +type mockBuildContext struct { + outputDir string + files map[string]string +} + +func newMockBuildContext(outputDir string) *mockBuildContext { + return &mockBuildContext{ + outputDir: outputDir, + files: make(map[string]string), + } +} + +func (m *mockBuildContext) OutputDir() string { + return m.outputDir +} + +func (m *mockBuildContext) Info(msg string, args ...any) { + // No-op for testing +} + +func (m *mockBuildContext) Warn(msg string, args ...any) { + // No-op for testing +} + +func (m *mockBuildContext) Error(msg string, args ...any) error { + return fmt.Errorf(msg, args...) +} + +func (m *mockBuildContext) VisitChildren(node ir.IRNode) error { + // No-op for testing + return nil +} + +func (m *mockBuildContext) DependsOn(target ir.IRNode, dependencies ...ir.IRNode) { + // No-op for testing +} + +func (m *mockBuildContext) ImplementsGolangNode(node ir.IRNode) error { + // No-op for testing + return nil +} + +func (m *mockBuildContext) ImplementsGolangService(node ir.IRNode) (*golang.Service, error) { + if service, ok := node.(*golang.Service); ok { + return service, nil + } + return nil, fmt.Errorf("node does not implement golang.Service") +} + +func (m *mockBuildContext) ImplementsGRPCServer(node ir.IRNode) (ir.IRNode, error) { + // No-op for testing + return nil, fmt.Errorf("not implemented") +} + +// WriteFile captures file content for testing +func (m *mockBuildContext) WriteFile(filename, content string) error { + m.files[filename] = content + return nil +} + +// ReadFile simulates reading a file +func (m *mockBuildContext) ReadFile(filename string) ([]byte, error) { + if content, ok := m.files[filename]; ok { + return []byte(content), nil + } + return nil, fmt.Errorf("file not found: %s", filename) +} + +// GetWrittenFile retrieves a file that was written during generation +func (m *mockBuildContext) GetWrittenFile(filename string) (string, bool) { + content, ok := m.files[filename] + return content, ok +} + +// GetAllFiles returns all written files +func (m *mockBuildContext) GetAllFiles() map[string]string { + return m.files +} + +// Additional methods to satisfy ir.BuildContext interface +func (m *mockBuildContext) Visited(node ir.IRNode) bool { + return false +} + +func (m *mockBuildContext) HasNode(name string) bool { + return false +} + +func (m *mockBuildContext) GetNode(name string) (ir.IRNode, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockBuildContext) VisitNode(name string, propagate bool, fn ir.VisitFunc) error { + return nil +} + +// Helper function to create a test deployment with containers +func createTestDeployment() *KubernetesDeployment { + // Create a test service + service := &golang.Service{ + Node: blueprint.Node{ + NodeName: "test-service", + }, + } + + // Create a test container + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: "test-container", + }, + ImageName: "test-image:latest", + Ports: map[string]*address.BindConfig{ + "http": { + Port: 8080, + }, + }, + EnvironmentVariables: map[string]string{ + "ENV_VAR_1": "value1", + "ENV_VAR_2": "value2", + }, + } + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{ + NodeName: "test-deployment", + }, + DeploymentName: "test-deployment", + Namespace: "test-namespace", + Replicas: 3, + Containers: []any{container}, + ClusterConfig: ClusterConfiguration{ + Endpoint: "https://k8s.example.com", + Kubeconfig: "/path/to/kubeconfig", + }, + } + + return deployment +} + +func TestGenerateArtifacts(t *testing.T) { + ctx := context.Background() + deployment := createTestDeployment() + mockCtx := newMockBuildContext("/tmp/test") + + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Check that deployment manifest was generated + deploymentYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "test-deployment-deployment.yaml")) + require.True(t, ok, "Deployment manifest should be generated") + assert.Contains(t, deploymentYAML, "kind: Deployment") + assert.Contains(t, deploymentYAML, "name: test-deployment") + assert.Contains(t, deploymentYAML, "namespace: test-namespace") + assert.Contains(t, deploymentYAML, "replicas: 3") + + // Check that service manifest was generated + serviceYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "test-container-service.yaml")) + require.True(t, ok, "Service manifest should be generated") + assert.Contains(t, serviceYAML, "kind: Service") + assert.Contains(t, serviceYAML, "name: test-container") + assert.Contains(t, serviceYAML, "port: 8080") + + // Check that ConfigMap was generated + configMapYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "test-deployment-configmap.yaml")) + require.True(t, ok, "ConfigMap should be generated") + assert.Contains(t, configMapYAML, "kind: ConfigMap") + assert.Contains(t, configMapYAML, "ENV_VAR_1: value1") + assert.Contains(t, configMapYAML, "ENV_VAR_2: value2") + + // Check that deployment scripts were generated + _, ok = mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "deploy.sh")) + require.True(t, ok, "Linux deployment script should be generated") + + _, ok = mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "deploy.bat")) + require.True(t, ok, "Windows deployment script should be generated") + + // Check that README was generated + readme, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "README.md")) + require.True(t, ok, "README should be generated") + assert.Contains(t, readme, "Kubernetes Deployment") + assert.Contains(t, readme, "test-deployment") +} + +func TestKubernetesWorkspace(t *testing.T) { + deployment := createTestDeployment() + workspace := &kubernetesWorkspace{ + deployment: deployment, + } + + // Test AddEnvironmentVariable + err := workspace.AddEnvironmentVariable("TEST_VAR", "test_value") + require.NoError(t, err) + + container := deployment.Containers[0].(*docker.Container) + assert.Equal(t, "test_value", container.EnvironmentVariables["TEST_VAR"]) + + // Test AddBindAddr + bindCfg := &address.BindConfig{ + Port: 9090, + } + err = workspace.AddBindAddr("grpc", bindCfg) + require.NoError(t, err) + assert.Equal(t, bindCfg, container.Ports["grpc"]) + + // Test AddContainerInstance + newContainer := &docker.Container{ + Node: blueprint.Node{ + NodeName: "new-container", + }, + ImageName: "new-image:latest", + } + err = workspace.AddContainerInstance(newContainer) + require.NoError(t, err) + assert.Len(t, deployment.Containers, 2) + assert.Equal(t, newContainer, deployment.Containers[1]) +} + +func TestProcessArgNodes(t *testing.T) { + // Create a deployment with multiple containers + container1 := &docker.Container{ + Node: blueprint.Node{ + NodeName: "service1", + }, + ImageName: "image1:latest", + Ports: map[string]*address.BindConfig{ + "http": {Port: 8080}, + }, + EnvironmentVariables: make(map[string]string), + } + + container2 := &docker.Container{ + Node: blueprint.Node{ + NodeName: "service2", + }, + ImageName: "image2:latest", + Ports: map[string]*address.BindConfig{ + "grpc": {Port: 9090}, + }, + EnvironmentVariables: make(map[string]string), + } + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{ + NodeName: "test-deployment", + }, + DeploymentName: "test-deployment", + Namespace: "default", + Containers: []any{container1, container2}, + } + + workspace := &kubernetesWorkspace{ + deployment: deployment, + } + + // Create pointers to services + ptr1 := &pointer.Pointer{ + Node: blueprint.Node{ + NodeName: "ptr_service1", + }, + Wrapped: container1, + } + + ptr2 := &pointer.Pointer{ + Node: blueprint.Node{ + NodeName: "ptr_service2", + }, + Wrapped: container2, + } + + // Process arg nodes with service references + args := []ir.IRNode{ptr1, ptr2} + processArgNodes(workspace, args) + + // Check that environment variables were added + assert.Equal(t, "service1:8080", container1.EnvironmentVariables["SERVICE1_ADDR"]) + assert.Equal(t, "service2:9090", container1.EnvironmentVariables["SERVICE2_ADDR"]) +} + +func TestManifestYAMLStructure(t *testing.T) { + ctx := context.Background() + deployment := createTestDeployment() + mockCtx := newMockBuildContext("/tmp/test") + + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Parse and validate deployment YAML structure + deploymentYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "test-deployment-deployment.yaml")) + require.True(t, ok) + + var deploymentObj map[string]interface{} + err = yaml.Unmarshal([]byte(deploymentYAML), &deploymentObj) + require.NoError(t, err, "Deployment YAML should be valid") + + // Validate top-level fields + assert.Equal(t, "apps/v1", deploymentObj["apiVersion"]) + assert.Equal(t, "Deployment", deploymentObj["kind"]) + + // Validate metadata + metadata := deploymentObj["metadata"].(map[string]interface{}) + assert.Equal(t, "test-deployment", metadata["name"]) + assert.Equal(t, "test-namespace", metadata["namespace"]) + + // Validate spec + spec := deploymentObj["spec"].(map[string]interface{}) + assert.Equal(t, 3, spec["replicas"]) +} + +func TestMultipleContainersDeployment(t *testing.T) { + ctx := context.Background() + + // Create deployment with multiple containers + container1 := &docker.Container{ + Node: blueprint.Node{NodeName: "frontend"}, + ImageName: "frontend:latest", + Ports: map[string]*address.BindConfig{ + "http": {Port: 3000}, + }, + EnvironmentVariables: map[string]string{ + "API_URL": "http://backend:8080", + }, + } + + container2 := &docker.Container{ + Node: blueprint.Node{NodeName: "backend"}, + ImageName: "backend:latest", + Ports: map[string]*address.BindConfig{ + "http": {Port: 8080}, + }, + EnvironmentVariables: map[string]string{ + "DB_HOST": "database", + }, + } + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: "multi-container-deployment"}, + DeploymentName: "multi-container", + Namespace: "production", + Replicas: 2, + Containers: []any{container1, container2}, + ClusterConfig: ClusterConfiguration{ + Endpoint: "https://prod.k8s.example.com", + }, + } + + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Check deployment has both containers + deploymentYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "multi-container-deployment.yaml")) + require.True(t, ok) + assert.Contains(t, deploymentYAML, "name: frontend") + assert.Contains(t, deploymentYAML, "name: backend") + assert.Contains(t, deploymentYAML, "image: frontend:latest") + assert.Contains(t, deploymentYAML, "image: backend:latest") + + // Check services for both containers + _, ok = mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "frontend-service.yaml")) + require.True(t, ok, "Frontend service should be generated") + + _, ok = mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "backend-service.yaml")) + require.True(t, ok, "Backend service should be generated") + + // Check ConfigMap has environment variables from both containers + configMapYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "multi-container-configmap.yaml")) + require.True(t, ok) + assert.Contains(t, configMapYAML, "API_URL") + assert.Contains(t, configMapYAML, "DB_HOST") +} + +func TestEmptyDeployment(t *testing.T) { + ctx := context.Background() + + // Create deployment with no containers + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: "empty-deployment"}, + DeploymentName: "empty", + Namespace: "default", + Replicas: 1, + Containers: []any{}, + } + + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Check that deployment manifest was still generated + deploymentYAML, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "empty-deployment.yaml")) + require.True(t, ok) + assert.Contains(t, deploymentYAML, "kind: Deployment") + + // Check that no services were generated + files := mockCtx.GetAllFiles() + for filename := range files { + assert.False(t, strings.Contains(filename, "-service.yaml"), "No service files should be generated for empty deployment") + } +} + +func TestDeploymentScripts(t *testing.T) { + ctx := context.Background() + deployment := createTestDeployment() + mockCtx := newMockBuildContext("/tmp/test") + + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Test Linux deployment script + linuxScript, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "deploy.sh")) + require.True(t, ok) + assert.Contains(t, linuxScript, "#!/bin/bash") + assert.Contains(t, linuxScript, "kubectl apply") + assert.Contains(t, linuxScript, "KUBECONFIG=/path/to/kubeconfig") + assert.Contains(t, linuxScript, "CLUSTER_ENDPOINT=https://k8s.example.com") + + // Test Windows deployment script + windowsScript, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "deploy.bat")) + require.True(t, ok) + assert.Contains(t, windowsScript, "@echo off") + assert.Contains(t, windowsScript, "kubectl apply") + assert.Contains(t, windowsScript, "set KUBECONFIG=/path/to/kubeconfig") +} + +func TestContainerWithoutPorts(t *testing.T) { + ctx := context.Background() + + // Create container without exposed ports + container := &docker.Container{ + Node: blueprint.Node{NodeName: "worker"}, + ImageName: "worker:latest", + Ports: map[string]*address.BindConfig{}, // No ports + EnvironmentVariables: map[string]string{"WORKER_ID": "123"}, + } + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: "worker-deployment"}, + DeploymentName: "worker", + Namespace: "default", + Replicas: 5, + Containers: []any{container}, + } + + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Check that no service was generated + files := mockCtx.GetAllFiles() + for filename := range files { + assert.False(t, strings.Contains(filename, "worker-service.yaml"), "No service should be generated for container without ports") + } + + // Check deployment was still generated + _, ok := mockCtx.GetWrittenFile(filepath.Join("/tmp/test", "kubernetes", "manifests", "worker-deployment.yaml")) + require.True(t, ok, "Deployment should be generated even without ports") +} + +func TestGenerateArtifactsWithInvalidContainer(t *testing.T) { + ctx := context.Background() + + // Create deployment with non-container node + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: "invalid-deployment"}, + DeploymentName: "invalid", + Namespace: "default", + Containers: []any{"not-a-container"}, // Invalid container + } + + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.Error(t, err, "Should error with invalid container type") + assert.Contains(t, err.Error(), "expected docker.Container") +} diff --git a/plugins/kubernetes/integration_test.go b/plugins/kubernetes/integration_test.go new file mode 100644 index 00000000..581740fc --- /dev/null +++ b/plugins/kubernetes/integration_test.go @@ -0,0 +1,460 @@ +package kubernetes + +import ( + "context" + "testing" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/pointer" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/wiring" + "github.com/blueprint-uservices/blueprint/plugins/clientpool" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/blueprint-uservices/blueprint/plugins/golang" + "github.com/blueprint-uservices/blueprint/plugins/grpc" + "github.com/blueprint-uservices/blueprint/plugins/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegrationWithGolangService tests integration with golang.Service nodes +func TestIntegrationWithGolangService(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("GolangServiceIntegration") + + // Create a Golang service + service := golang.Service(spec, "myservice") + golang.Deploy(spec, service) + + // Create Kubernetes deployment and add the service + deployment := NewDeployment(spec, "k8s-deployment") + AddContainerToDeployment(spec, deployment, service) + ConfigureCluster(spec, deployment, "https://k8s.example.com", "/path/to/kubeconfig", "") + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Find the deployment node + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + + // Verify the service was added as a container + assert.Len(t, deploymentNode.Containers, 1) + container, ok := deploymentNode.Containers[0].(*docker.Container) + require.True(t, ok) + assert.Equal(t, "myservice", container.Name()) +} + +// TestIntegrationWithHTTPServer tests integration with HTTP server +func TestIntegrationWithHTTPServer(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("HTTPServerIntegration") + + // Create a service with HTTP server + service := golang.Service(spec, "api-service") + httpServer := http.HTTPServer(spec, service, "8080") + golang.Deploy(spec, httpServer) + + // Create Kubernetes deployment + deployment := NewDeployment(spec, "api-deployment") + AddContainerToDeployment(spec, deployment, httpServer) + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Find nodes + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + + // Verify HTTP port was exposed + container, ok := deploymentNode.Containers[0].(*docker.Container) + require.True(t, ok) + assert.Contains(t, container.Ports, "http") + assert.Equal(t, 8080, container.Ports["http"].Port) +} + +// TestIntegrationWithGRPCServer tests integration with gRPC server +func TestIntegrationWithGRPCServer(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("GRPCServerIntegration") + + // Create a service with gRPC server + service := golang.Service(spec, "grpc-service") + grpcServer := grpc.GRPCServer(spec, service, "9090") + golang.Deploy(spec, grpcServer) + + // Create Kubernetes deployment + deployment := NewDeployment(spec, "grpc-deployment") + AddContainerToDeployment(spec, deployment, grpcServer) + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Find nodes + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + + // Verify gRPC port was exposed + container, ok := deploymentNode.Containers[0].(*docker.Container) + require.True(t, ok) + assert.Contains(t, container.Ports, "grpc") + assert.Equal(t, 9090, container.Ports["grpc"].Port) +} + +// TestIntegrationWithMultipleServices tests deployment with multiple interconnected services +func TestIntegrationWithMultipleServices(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("MultiServiceIntegration") + + // Create frontend service + frontend := golang.Service(spec, "frontend") + frontendHTTP := http.HTTPServer(spec, frontend, "3000") + + // Create backend service + backend := golang.Service(spec, "backend") + backendGRPC := grpc.GRPCServer(spec, backend, "9090") + + // Create client connection from frontend to backend + backendClient := grpc.GRPCClient(spec, frontendHTTP, backendGRPC) + + // Deploy services + golang.Deploy(spec, frontendHTTP) + golang.Deploy(spec, backendGRPC) + + // Create Kubernetes deployment with both services + deployment := NewDeployment(spec, "multi-service-deployment") + AddContainerToDeployment(spec, deployment, frontendHTTP) + AddContainerToDeployment(spec, deployment, backendGRPC) + SetNamespace(spec, deployment, "production") + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Find deployment + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + + // Verify both services are in the deployment + assert.Len(t, deploymentNode.Containers, 2) + + // Generate artifacts to test service discovery + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err = deploymentNode.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Check that environment variables for service discovery were added + frontendContainer := deploymentNode.Containers[0].(*docker.Container) + assert.Contains(t, frontendContainer.EnvironmentVariables, "BACKEND_ADDR") +} + +// TestIntegrationWithClientPool tests integration with client pool +func TestIntegrationWithClientPool(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("ClientPoolIntegration") + + // Create services + service := golang.Service(spec, "pooled-service") + httpServer := http.HTTPServer(spec, service, "8080") + + // Add client pool + pooledServer := clientpool.Pool(spec, httpServer, 10) + golang.Deploy(spec, pooledServer) + + // Create Kubernetes deployment + deployment := NewDeployment(spec, "pooled-deployment") + AddContainerToDeployment(spec, deployment, pooledServer) + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Verify deployment was created correctly + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + assert.Len(t, deploymentNode.Containers, 1) +} + +// TestIntegrationWithNamespaces tests integration with Blueprint namespaces +func TestIntegrationWithNamespaces(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("NamespaceIntegration") + + // Create services in different namespaces + spec.AddNamespace("frontend", "frontend-ns", nil) + spec.AddNamespace("backend", "backend-ns", nil) + + // Create frontend service + frontendService := golang.Service(spec, "frontend-service") + spec.PlaceInNamespace("frontend", frontendService) + + // Create backend service + backendService := golang.Service(spec, "backend-service") + spec.PlaceInNamespace("backend", backendService) + + // Deploy services + golang.Deploy(spec, frontendService) + golang.Deploy(spec, backendService) + + // Build IR + _, err := spec.BuildIR() + require.NoError(t, err) +} + +// TestIntegrationComplexMicroserviceDeployment tests a complex microservice deployment scenario +func TestIntegrationComplexMicroserviceDeployment(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("ComplexMicroserviceDeployment") + + // Create API Gateway + apiGateway := golang.Service(spec, "api-gateway") + apiGatewayHTTP := http.HTTPServer(spec, apiGateway, "80") + + // Create User Service + userService := golang.Service(spec, "user-service") + userServiceGRPC := grpc.GRPCServer(spec, userService, "9001") + + // Create Order Service + orderService := golang.Service(spec, "order-service") + orderServiceGRPC := grpc.GRPCServer(spec, orderService, "9002") + + // Create Payment Service + paymentService := golang.Service(spec, "payment-service") + paymentServiceGRPC := grpc.GRPCServer(spec, paymentService, "9003") + + // Create connections + userClient := grpc.GRPCClient(spec, apiGatewayHTTP, userServiceGRPC) + orderClient := grpc.GRPCClient(spec, apiGatewayHTTP, orderServiceGRPC) + paymentClient := grpc.GRPCClient(spec, orderServiceGRPC, paymentServiceGRPC) + + // Deploy all services + golang.Deploy(spec, apiGatewayHTTP) + golang.Deploy(spec, userServiceGRPC) + golang.Deploy(spec, orderServiceGRPC) + golang.Deploy(spec, paymentServiceGRPC) + + // Create Kubernetes deployments + apiDeployment := NewDeployment(spec, "api-gateway-deployment") + AddContainerToDeployment(spec, apiDeployment, apiGatewayHTTP) + SetReplicas(spec, apiDeployment, 3) + + servicesDeployment := NewDeployment(spec, "services-deployment") + AddContainerToDeployment(spec, servicesDeployment, userServiceGRPC) + AddContainerToDeployment(spec, servicesDeployment, orderServiceGRPC) + AddContainerToDeployment(spec, servicesDeployment, paymentServiceGRPC) + SetReplicas(spec, servicesDeployment, 2) + + // Configure cluster for both deployments + ConfigureCluster(spec, apiDeployment, "https://prod.k8s.example.com", "/etc/kubeconfig", "") + ConfigureCluster(spec, servicesDeployment, "https://prod.k8s.example.com", "/etc/kubeconfig", "") + + // Build IR + app, err := buildTestApp(t, spec, apiDeployment, servicesDeployment) + require.NoError(t, err) + + // Find deployments + deployments := findAllNodesOfType[*KubernetesDeployment](app) + assert.Len(t, deployments, 2) + + // Generate artifacts for services deployment + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + + for _, deployment := range deployments { + if deployment.DeploymentName == "services-deployment" { + err = deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err) + + // Verify all services were included + assert.Len(t, deployment.Containers, 3) + + // Check that service discovery environment variables were set + for _, container := range deployment.Containers { + c := container.(*docker.Container) + // Each service should know about the others + if c.Name() == "order-service" { + assert.Contains(t, c.EnvironmentVariables, "PAYMENT_SERVICE_ADDR") + } + } + } + } +} + +// TestIntegrationWithPointerNodes tests handling of pointer nodes +func TestIntegrationWithPointerNodes(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("PointerNodeIntegration") + + // Create a service + service := golang.Service(spec, "pointed-service") + httpServer := http.HTTPServer(spec, service, "8080") + + // Create a pointer to the service + servicePtr := pointer.CreatePointer(spec, httpServer) + + // Deploy the pointed service + golang.Deploy(spec, servicePtr) + + // Create deployment with the pointer + deployment := NewDeployment(spec, "pointer-deployment") + AddContainerToDeployment(spec, deployment, servicePtr) + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Verify the deployment correctly resolved the pointer + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + assert.Len(t, deploymentNode.Containers, 1) +} + +// TestIntegrationArtifactGeneration tests end-to-end artifact generation +func TestIntegrationArtifactGeneration(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("ArtifactGenerationIntegration") + + // Create a complete microservice setup + deployment, containers := helpers.CreateMicroserviceSetup() + + // Add containers to wiring spec + for _, container := range containers { + spec.AddNode(container.Name(), container) + } + spec.AddNode(deployment.Name(), deployment) + + // Generate artifacts + ctx := helpers.GenerateTestArtifacts(deployment, "/tmp/integration-test") + + // Verify all expected files were generated + helpers.AssertManifestCount(ctx, 7) // 1 deployment + 4 services + 1 configmap + 1 for deployment without services + + // Verify deployment manifest + deploymentYAML := helpers.AssertFileGenerated(ctx, + helpers.GetManifestPath("/tmp/integration-test", "microservice-app-deployment.yaml")) + helpers.ValidateDeploymentManifest(deploymentYAML, "microservice-app", 3) + + // Verify service manifests + frontendService := helpers.AssertFileGenerated(ctx, + helpers.GetManifestPath("/tmp/integration-test", "frontend-service.yaml")) + helpers.ValidateServiceManifest(frontendService, "frontend", 3000) + + apiService := helpers.AssertFileGenerated(ctx, + helpers.GetManifestPath("/tmp/integration-test", "api-service.yaml")) + helpers.ValidateServiceManifest(apiService, "api", 8080) + + // Verify scripts + helpers.AssertScriptContains(ctx, + helpers.GetScriptPath("/tmp/integration-test", "deploy.sh"), + []string{ + "#!/bin/bash", + "kubectl apply", + "KUBECONFIG=/etc/kubeconfig", + }) + + // Verify README + readme := helpers.AssertFileGenerated(ctx, + helpers.GetScriptPath("/tmp/integration-test", "README.md")) + assert.Contains(t, readme, "Kubernetes Deployment") + assert.Contains(t, readme, "microservice-app") +} + +// Helper functions for integration tests + +func buildTestApp(t *testing.T, spec wiring.WiringSpec, nodes ...ir.IRNode) (*ir.ApplicationNode, error) { + // Add nodes to spec if not already added + for _, node := range nodes { + if !spec.HasNode(node.Name()) { + spec.AddNode(node.Name(), node) + } + } + + app, err := spec.BuildIR() + if err != nil { + return nil, err + } + + return app.(*ir.ApplicationNode), nil +} + +func findNodeOfType[T ir.IRNode](app *ir.ApplicationNode) T { + var result T + ir.VisitNodes(app, func(node ir.IRNode) error { + if n, ok := node.(T); ok { + result = n + return ir.StopVisiting + } + return nil + }) + return result +} + +func findAllNodesOfType[T ir.IRNode](app *ir.ApplicationNode) []T { + var results []T + ir.VisitNodes(app, func(node ir.IRNode) error { + if n, ok := node.(T); ok { + results = append(results, n) + } + return nil + }) + return results +} + +// TestIntegrationWithExistingDockerPlugin tests that our plugin works well with existing docker plugin +func TestIntegrationWithExistingDockerPlugin(t *testing.T) { + helpers := NewTestHelpers(t) + spec := helpers.CreateTestWiringSpec("DockerPluginIntegration") + + // Create a service and deploy it with docker + service := golang.Service(spec, "docker-service") + dockerContainer := docker.Container(spec, service) + docker.Deploy(spec, dockerContainer) + + // Create Kubernetes deployment and add the docker container + deployment := NewDeployment(spec, "k8s-docker-deployment") + AddContainerToDeployment(spec, deployment, dockerContainer) + + // Build IR + app, err := buildTestApp(t, spec, deployment) + require.NoError(t, err) + + // Verify the deployment includes the docker container + deploymentNode := findNodeOfType[*KubernetesDeployment](app) + require.NotNil(t, deploymentNode) + assert.Len(t, deploymentNode.Containers, 1) + + container := deploymentNode.Containers[0].(*docker.Container) + assert.Equal(t, "docker-service", container.Name()) +} + +// TestIntegrationErrorHandling tests error handling in integration scenarios +func TestIntegrationErrorHandling(t *testing.T) { + helpers := NewTestHelpers(t) + + t.Run("InvalidContainerType", func(t *testing.T) { + spec := helpers.CreateTestWiringSpec("InvalidContainer") + + // Create deployment and try to add non-container node + deployment := NewDeployment(spec, "bad-deployment") + + // This should handle gracefully when building IR + spec.AddNode("not-a-container", "string-value") + AddContainerToDeployment(spec, deployment, "not-a-container") + + _, err := buildTestApp(t, spec, deployment) + // The error might occur during IR building or artifact generation + // depending on Blueprint's validation timing + if err == nil { + // If no error during build, check artifact generation + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err = deployment.GenerateArtifacts(ctx, mockCtx) + } + + // We expect an error at some point + assert.Error(t, err) + }) +} diff --git a/plugins/kubernetes/ir.go b/plugins/kubernetes/ir.go new file mode 100644 index 00000000..bd44d3de --- /dev/null +++ b/plugins/kubernetes/ir.go @@ -0,0 +1,57 @@ +package kubernetes + +import ( + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" +) + +// KubernetesDeployment is an IRNode representing a Kubernetes deployment, +// which is a collection of container instances that will be deployed to a +// Kubernetes cluster. +type KubernetesDeployment struct { + /* The implemented build targets for kubernetes.KubernetesDeployment nodes */ + kubernetesDeployer /* Can be deployed as Kubernetes manifests; implemented in deploy.go */ + + DeploymentName string + Namespace string + Replicas int32 + ClusterConfig *ClusterConfiguration + Nodes []ir.IRNode + Edges []ir.IRNode +} + +// ClusterConfiguration holds runtime configuration for connecting to a Kubernetes cluster +type ClusterConfiguration struct { + // Path to kubeconfig file (optional - can be provided at runtime) + KubeconfigPath string + // Kubernetes API server endpoint (optional - can be provided at runtime) + APIServer string + // Authentication token (optional - can be provided at runtime) + Token string + // Namespace to deploy to (can be overridden at runtime) + Namespace string +} + +// Implements IRNode +func (node *KubernetesDeployment) Name() string { + return node.DeploymentName +} + +// Implements IRNode +func (node *KubernetesDeployment) String() string { + return ir.PrettyPrintNamespace(node.DeploymentName, "KubernetesDeployment", node.Edges, node.Nodes) +} + +// SetNamespace sets the Kubernetes namespace for this deployment +func (node *KubernetesDeployment) SetNamespace(namespace string) { + node.Namespace = namespace +} + +// SetReplicas sets the number of replicas for this deployment +func (node *KubernetesDeployment) SetReplicas(replicas int32) { + node.Replicas = replicas +} + +// SetClusterConfig sets the cluster configuration for this deployment +func (node *KubernetesDeployment) SetClusterConfig(config *ClusterConfiguration) { + node.ClusterConfig = config +} diff --git a/plugins/kubernetes/ir_test.go b/plugins/kubernetes/ir_test.go new file mode 100644 index 00000000..cd45242a --- /dev/null +++ b/plugins/kubernetes/ir_test.go @@ -0,0 +1,254 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKubernetesDeploymentCreation(t *testing.T) { + // Test basic deployment creation + deployment := &KubernetesDeployment{ + DeploymentName: "test-deployment", + Namespace: "default", + Replicas: 1, + Containers: []any{}, + ClusterConfig: ClusterConfiguration{ + Endpoint: "https://k8s.example.com", + Kubeconfig: "/path/to/config", + AuthToken: "token123", + }, + } + + assert.Equal(t, "test-deployment", deployment.DeploymentName) + assert.Equal(t, "default", deployment.Namespace) + assert.Equal(t, 1, deployment.Replicas) + assert.Empty(t, deployment.Containers) +} + +func TestKubernetesDeploymentName(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "my-app", + } + + assert.Equal(t, "my-app", deployment.Name()) +} + +func TestKubernetesDeploymentString(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "test-app", + Namespace: "production", + Replicas: 3, + } + + result := deployment.String() + assert.Contains(t, result, "KubernetesDeployment") + assert.Contains(t, result, "test-app") + assert.Contains(t, result, "namespace=production") + assert.Contains(t, result, "replicas=3") +} + +func TestKubernetesDeploymentImplementsDockerContainer(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "test", + } + + assert.False(t, deployment.ImplementsDockerContainer()) +} + +func TestKubernetesDeploymentImplementsDockerWorkspace(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "test", + } + + assert.True(t, deployment.ImplementsDockerWorkspace()) +} + +func TestKubernetesDeploymentAddContainer(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "test", + Containers: []any{}, + } + + // Mock container objects + container1 := "container1" + container2 := "container2" + + deployment.Containers = append(deployment.Containers, container1) + deployment.Containers = append(deployment.Containers, container2) + + assert.Len(t, deployment.Containers, 2) + assert.Equal(t, "container1", deployment.Containers[0]) + assert.Equal(t, "container2", deployment.Containers[1]) +} + +func TestClusterConfiguration(t *testing.T) { + tests := []struct { + name string + config ClusterConfiguration + want struct { + hasEndpoint bool + hasKubeconfig bool + hasToken bool + } + }{ + { + name: "full configuration", + config: ClusterConfiguration{ + Endpoint: "https://k8s.example.com:6443", + Kubeconfig: "/home/user/.kube/config", + AuthToken: "bearer-token", + }, + want: struct { + hasEndpoint bool + hasKubeconfig bool + hasToken bool + }{true, true, true}, + }, + { + name: "endpoint only", + config: ClusterConfiguration{ + Endpoint: "https://k8s.example.com", + }, + want: struct { + hasEndpoint bool + hasKubeconfig bool + hasToken bool + }{true, false, false}, + }, + { + name: "kubeconfig only", + config: ClusterConfiguration{ + Kubeconfig: "/path/to/kubeconfig", + }, + want: struct { + hasEndpoint bool + hasKubeconfig bool + hasToken bool + }{false, true, false}, + }, + { + name: "empty configuration", + config: ClusterConfiguration{}, + want: struct { + hasEndpoint bool + hasKubeconfig bool + hasToken bool + }{false, false, false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want.hasEndpoint, tt.config.Endpoint != "") + assert.Equal(t, tt.want.hasKubeconfig, tt.config.Kubeconfig != "") + assert.Equal(t, tt.want.hasToken, tt.config.AuthToken != "") + }) + } +} + +func TestKubernetesDeploymentDefaults(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "test", + } + + // Test default values + assert.Equal(t, "", deployment.Namespace, "Namespace should be empty by default") + assert.Equal(t, 0, deployment.Replicas, "Replicas should be 0 by default") + assert.NotNil(t, deployment.Containers, "Containers should not be nil") + assert.Empty(t, deployment.Containers, "Containers should be empty") +} + +func TestKubernetesDeploymentWithMultipleContainers(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "multi-container-app", + Namespace: "production", + Replicas: 5, + Containers: []any{}, + } + + // Add multiple containers + containers := []string{"frontend", "backend", "database", "cache", "queue"} + for _, c := range containers { + deployment.Containers = append(deployment.Containers, c) + } + + assert.Len(t, deployment.Containers, 5) + assert.Equal(t, "multi-container-app", deployment.Name()) + assert.Equal(t, "production", deployment.Namespace) + assert.Equal(t, 5, deployment.Replicas) +} + +func TestKubernetesDeploymentStringWithContainers(t *testing.T) { + deployment := &KubernetesDeployment{ + DeploymentName: "app-with-containers", + Namespace: "staging", + Replicas: 2, + Containers: []any{"service1", "service2"}, + } + + result := deployment.String() + assert.Contains(t, result, "KubernetesDeployment") + assert.Contains(t, result, "app-with-containers") + assert.Contains(t, result, "namespace=staging") + assert.Contains(t, result, "replicas=2") + assert.Contains(t, result, "containers=2") +} + +func TestKubernetesDeploymentClusterConfigValidation(t *testing.T) { + tests := []struct { + name string + deployment *KubernetesDeployment + expectedValid bool + }{ + { + name: "valid with endpoint", + deployment: &KubernetesDeployment{ + DeploymentName: "test", + ClusterConfig: ClusterConfiguration{ + Endpoint: "https://k8s.example.com", + }, + }, + expectedValid: true, + }, + { + name: "valid with kubeconfig", + deployment: &KubernetesDeployment{ + DeploymentName: "test", + ClusterConfig: ClusterConfiguration{ + Kubeconfig: "/path/to/config", + }, + }, + expectedValid: true, + }, + { + name: "valid with both", + deployment: &KubernetesDeployment{ + DeploymentName: "test", + ClusterConfig: ClusterConfiguration{ + Endpoint: "https://k8s.example.com", + Kubeconfig: "/path/to/config", + }, + }, + expectedValid: true, + }, + { + name: "empty config is valid (can be set at runtime)", + deployment: &KubernetesDeployment{ + DeploymentName: "test", + ClusterConfig: ClusterConfiguration{}, + }, + expectedValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // In this implementation, all configs are valid since they can be set at runtime + // This test documents that behavior + require.NotNil(t, tt.deployment) + assert.Equal(t, tt.expectedValid, true) + }) + } +} diff --git a/plugins/kubernetes/kubernetesgen/manifest_builder.go b/plugins/kubernetes/kubernetesgen/manifest_builder.go new file mode 100644 index 00000000..517e9700 --- /dev/null +++ b/plugins/kubernetes/kubernetesgen/manifest_builder.go @@ -0,0 +1,458 @@ +package kubernetesgen + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ManifestBuilder builds Kubernetes YAML manifests for deployment +type ManifestBuilder struct { + deploymentName string + namespace string + replicas int32 + outputDir string + + containers map[string]*ContainerInfo + envVars map[string]map[string]string // container -> env var -> value + ports map[string][]PortInfo // container -> ports + passthroughEnv map[string][]string // container -> env var names to passthrough +} + +// ContainerInfo holds information about a container +type ContainerInfo struct { + Name string + Image string + IsLocal bool + Ports []PortInfo + EnvVars map[string]string +} + +// PortInfo holds information about a port +type PortInfo struct { + Name string + Port int + ContainerPort int + Protocol string +} + +// NewManifestBuilder creates a new ManifestBuilder +func NewManifestBuilder(deploymentName, namespace string, replicas int32, outputDir string) *ManifestBuilder { + if namespace == "" { + namespace = "default" + } + if replicas <= 0 { + replicas = 1 + } + return &ManifestBuilder{ + deploymentName: deploymentName, + namespace: namespace, + replicas: replicas, + outputDir: outputDir, + containers: make(map[string]*ContainerInfo), + envVars: make(map[string]map[string]string), + ports: make(map[string][]PortInfo), + passthroughEnv: make(map[string][]string), + } +} + +// AddContainer adds a container to the deployment +func (m *ManifestBuilder) AddContainer(name, image string, isLocal bool) error { + if _, exists := m.containers[name]; exists { + return fmt.Errorf("container %s already exists", name) + } + m.containers[name] = &ContainerInfo{ + Name: name, + Image: image, + IsLocal: isLocal, + EnvVars: make(map[string]string), + } + m.envVars[name] = make(map[string]string) + return nil +} + +// AddEnvVar adds an environment variable to a container +func (m *ManifestBuilder) AddEnvVar(containerName, key, value string) error { + if _, exists := m.containers[containerName]; !exists { + return fmt.Errorf("container %s does not exist", containerName) + } + if m.envVars[containerName] == nil { + m.envVars[containerName] = make(map[string]string) + } + m.envVars[containerName][key] = value + return nil +} + +// PassthroughEnvVar marks an environment variable to be passed through from the host +func (m *ManifestBuilder) PassthroughEnvVar(containerName, key string, optional bool) error { + if _, exists := m.containers[containerName]; !exists { + return fmt.Errorf("container %s does not exist", containerName) + } + if m.passthroughEnv[containerName] == nil { + m.passthroughEnv[containerName] = []string{} + } + m.passthroughEnv[containerName] = append(m.passthroughEnv[containerName], key) + return nil +} + +// ExposePort exposes a port for a container and creates a service +func (m *ManifestBuilder) ExposePort(containerName string, port int, portName string) error { + if _, exists := m.containers[containerName]; !exists { + return fmt.Errorf("container %s does not exist", containerName) + } + if m.ports[containerName] == nil { + m.ports[containerName] = []PortInfo{} + } + m.ports[containerName] = append(m.ports[containerName], PortInfo{ + Name: portName, + Port: port, + ContainerPort: port, + Protocol: "TCP", + }) + return nil +} + +// Generate creates all Kubernetes manifest files +func (m *ManifestBuilder) Generate() error { + // Generate deployment manifest + if err := m.generateDeployment(); err != nil { + return fmt.Errorf("failed to generate deployment: %w", err) + } + + // Generate service manifests + if err := m.generateServices(); err != nil { + return fmt.Errorf("failed to generate services: %w", err) + } + + // Generate ConfigMap if there are environment variables + if err := m.generateConfigMap(); err != nil { + return fmt.Errorf("failed to generate configmap: %w", err) + } + + // Generate apply script + if err := m.generateApplyScript(); err != nil { + return fmt.Errorf("failed to generate apply script: %w", err) + } + + // Generate README + if err := m.generateReadme(); err != nil { + return fmt.Errorf("failed to generate README: %w", err) + } + + return nil +} + +// generateDeployment creates the Kubernetes Deployment manifest +func (m *ManifestBuilder) generateDeployment() error { + var yaml strings.Builder + + yaml.WriteString("apiVersion: apps/v1\n") + yaml.WriteString("kind: Deployment\n") + yaml.WriteString("metadata:\n") + yaml.WriteString(fmt.Sprintf(" name: %s\n", m.deploymentName)) + yaml.WriteString(fmt.Sprintf(" namespace: %s\n", m.namespace)) + yaml.WriteString("spec:\n") + yaml.WriteString(fmt.Sprintf(" replicas: %d\n", m.replicas)) + yaml.WriteString(" selector:\n") + yaml.WriteString(" matchLabels:\n") + yaml.WriteString(fmt.Sprintf(" app: %s\n", m.deploymentName)) + yaml.WriteString(" template:\n") + yaml.WriteString(" metadata:\n") + yaml.WriteString(" labels:\n") + yaml.WriteString(fmt.Sprintf(" app: %s\n", m.deploymentName)) + yaml.WriteString(" spec:\n") + yaml.WriteString(" containers:\n") + + // Add each container to the deployment + for _, container := range m.containers { + yaml.WriteString(fmt.Sprintf(" - name: %s\n", container.Name)) + + // Use the image name or local build reference + if container.IsLocal { + // For local images, we'll need to specify a registry + // This should be configured at runtime + yaml.WriteString(fmt.Sprintf(" image: ${REGISTRY}/%s:latest\n", container.Name)) + } else { + yaml.WriteString(fmt.Sprintf(" image: %s\n", container.Image)) + } + + // Add ports if any + if ports, exists := m.ports[container.Name]; exists && len(ports) > 0 { + yaml.WriteString(" ports:\n") + for _, port := range ports { + yaml.WriteString(fmt.Sprintf(" - containerPort: %d\n", port.ContainerPort)) + yaml.WriteString(fmt.Sprintf(" name: %s\n", port.Name)) + yaml.WriteString(fmt.Sprintf(" protocol: %s\n", port.Protocol)) + } + } + + // Add environment variables + envVars := m.envVars[container.Name] + passthroughVars := m.passthroughEnv[container.Name] + + if len(envVars) > 0 || len(passthroughVars) > 0 { + yaml.WriteString(" env:\n") + + // Add direct environment variables + for key, value := range envVars { + yaml.WriteString(fmt.Sprintf(" - name: %s\n", key)) + yaml.WriteString(fmt.Sprintf(" value: \"%s\"\n", value)) + } + + // Add passthrough environment variables from ConfigMap + for _, key := range passthroughVars { + yaml.WriteString(fmt.Sprintf(" - name: %s\n", key)) + yaml.WriteString(" valueFrom:\n") + yaml.WriteString(" configMapKeyRef:\n") + yaml.WriteString(fmt.Sprintf(" name: %s-config\n", m.deploymentName)) + yaml.WriteString(fmt.Sprintf(" key: %s\n", key)) + yaml.WriteString(" optional: true\n") + } + } + } + + // Write to file + deploymentFile := filepath.Join(m.outputDir, "deployment.yaml") + return os.WriteFile(deploymentFile, []byte(yaml.String()), 0644) +} + +// generateServices creates Kubernetes Service manifests for containers with exposed ports +func (m *ManifestBuilder) generateServices() error { + var yaml strings.Builder + + for containerName, ports := range m.ports { + if len(ports) == 0 { + continue + } + + // Each container with ports gets its own service + yaml.WriteString("---\n") + yaml.WriteString("apiVersion: v1\n") + yaml.WriteString("kind: Service\n") + yaml.WriteString("metadata:\n") + yaml.WriteString(fmt.Sprintf(" name: %s\n", containerName)) + yaml.WriteString(fmt.Sprintf(" namespace: %s\n", m.namespace)) + yaml.WriteString("spec:\n") + yaml.WriteString(" selector:\n") + yaml.WriteString(fmt.Sprintf(" app: %s\n", m.deploymentName)) + yaml.WriteString(" ports:\n") + + for _, port := range ports { + yaml.WriteString(fmt.Sprintf(" - port: %d\n", port.Port)) + yaml.WriteString(fmt.Sprintf(" targetPort: %d\n", port.ContainerPort)) + yaml.WriteString(fmt.Sprintf(" protocol: %s\n", port.Protocol)) + yaml.WriteString(fmt.Sprintf(" name: %s\n", port.Name)) + } + + yaml.WriteString(" type: ClusterIP\n") + } + + if yaml.Len() > 0 { + servicesFile := filepath.Join(m.outputDir, "services.yaml") + return os.WriteFile(servicesFile, []byte(yaml.String()), 0644) + } + + return nil +} + +// generateConfigMap creates a ConfigMap for environment variables +func (m *ManifestBuilder) generateConfigMap() error { + // Check if we need a ConfigMap + hasPassthrough := false + for _, vars := range m.passthroughEnv { + if len(vars) > 0 { + hasPassthrough = true + break + } + } + + if !hasPassthrough { + return nil + } + + var yaml strings.Builder + + yaml.WriteString("apiVersion: v1\n") + yaml.WriteString("kind: ConfigMap\n") + yaml.WriteString("metadata:\n") + yaml.WriteString(fmt.Sprintf(" name: %s-config\n", m.deploymentName)) + yaml.WriteString(fmt.Sprintf(" namespace: %s\n", m.namespace)) + yaml.WriteString("data:\n") + + // Collect all unique passthrough variables + uniqueVars := make(map[string]bool) + for _, vars := range m.passthroughEnv { + for _, v := range vars { + uniqueVars[v] = true + } + } + + // Add placeholders for each variable + for varName := range uniqueVars { + yaml.WriteString(fmt.Sprintf(" %s: \"${%s}\"\n", varName, varName)) + } + + configMapFile := filepath.Join(m.outputDir, "configmap.yaml") + return os.WriteFile(configMapFile, []byte(yaml.String()), 0644) +} + +// generateApplyScript creates a script to apply all manifests +func (m *ManifestBuilder) generateApplyScript() error { + var script strings.Builder + + script.WriteString("#!/bin/bash\n\n") + script.WriteString("# Script to deploy the Kubernetes manifests\n\n") + + script.WriteString("# Check if kubectl is installed\n") + script.WriteString("if ! command -v kubectl &> /dev/null; then\n") + script.WriteString(" echo \"kubectl is not installed. Please install kubectl first.\"\n") + script.WriteString(" exit 1\n") + script.WriteString("fi\n\n") + + script.WriteString("# Create namespace if it doesn't exist\n") + script.WriteString(fmt.Sprintf("kubectl create namespace %s --dry-run=client -o yaml | kubectl apply -f -\n\n", m.namespace)) + + script.WriteString("# Apply manifests\n") + script.WriteString("echo \"Applying Kubernetes manifests...\"\n\n") + + // Check if files exist and apply them + script.WriteString("if [ -f configmap.yaml ]; then\n") + script.WriteString(" echo \"Applying ConfigMap...\"\n") + script.WriteString(" kubectl apply -f configmap.yaml\n") + script.WriteString("fi\n\n") + + script.WriteString("if [ -f services.yaml ]; then\n") + script.WriteString(" echo \"Applying Services...\"\n") + script.WriteString(" kubectl apply -f services.yaml\n") + script.WriteString("fi\n\n") + + script.WriteString("echo \"Applying Deployment...\"\n") + script.WriteString("kubectl apply -f deployment.yaml\n\n") + + script.WriteString("echo \"Deployment complete!\"\n") + script.WriteString(fmt.Sprintf("echo \"To check status: kubectl get pods -n %s\"\n", m.namespace)) + script.WriteString(fmt.Sprintf("echo \"To view logs: kubectl logs -n %s -l app=%s\"\n", m.namespace, m.deploymentName)) + + scriptFile := filepath.Join(m.outputDir, "apply.sh") + if err := os.WriteFile(scriptFile, []byte(script.String()), 0755); err != nil { + return err + } + + // Also create a Windows batch file + var batch strings.Builder + batch.WriteString("@echo off\n\n") + batch.WriteString("REM Script to deploy the Kubernetes manifests\n\n") + batch.WriteString("REM Check if kubectl is installed\n") + batch.WriteString("where kubectl >nul 2>nul\n") + batch.WriteString("if %ERRORLEVEL% NEQ 0 (\n") + batch.WriteString(" echo kubectl is not installed. Please install kubectl first.\n") + batch.WriteString(" exit /b 1\n") + batch.WriteString(")\n\n") + + batch.WriteString("REM Create namespace if it doesn't exist\n") + batch.WriteString(fmt.Sprintf("kubectl create namespace %s --dry-run=client -o yaml | kubectl apply -f -\n\n", m.namespace)) + + batch.WriteString("REM Apply manifests\n") + batch.WriteString("echo Applying Kubernetes manifests...\n\n") + + batch.WriteString("if exist configmap.yaml (\n") + batch.WriteString(" echo Applying ConfigMap...\n") + batch.WriteString(" kubectl apply -f configmap.yaml\n") + batch.WriteString(")\n\n") + + batch.WriteString("if exist services.yaml (\n") + batch.WriteString(" echo Applying Services...\n") + batch.WriteString(" kubectl apply -f services.yaml\n") + batch.WriteString(")\n\n") + + batch.WriteString("echo Applying Deployment...\n") + batch.WriteString("kubectl apply -f deployment.yaml\n\n") + + batch.WriteString("echo Deployment complete!\n") + batch.WriteString(fmt.Sprintf("echo To check status: kubectl get pods -n %s\n", m.namespace)) + batch.WriteString(fmt.Sprintf("echo To view logs: kubectl logs -n %s -l app=%s\n", m.namespace, m.deploymentName)) + + batchFile := filepath.Join(m.outputDir, "apply.bat") + return os.WriteFile(batchFile, []byte(batch.String()), 0644) +} + +// generateReadme creates a README file with deployment instructions +func (m *ManifestBuilder) generateReadme() error { + var readme strings.Builder + + readme.WriteString(fmt.Sprintf("# Kubernetes Deployment: %s\n\n", m.deploymentName)) + readme.WriteString("This directory contains Kubernetes manifests for deploying the application.\n\n") + + readme.WriteString("## Prerequisites\n\n") + readme.WriteString("1. Access to a Kubernetes cluster\n") + readme.WriteString("2. `kubectl` configured to connect to your cluster\n") + readme.WriteString("3. Required environment variables set (see below)\n\n") + + readme.WriteString("## Files\n\n") + readme.WriteString("- `deployment.yaml` - Kubernetes Deployment resource\n") + readme.WriteString("- `services.yaml` - Kubernetes Service resources for networking\n") + readme.WriteString("- `configmap.yaml` - ConfigMap for environment variables\n") + readme.WriteString("- `apply.sh` / `apply.bat` - Scripts to deploy all resources\n\n") + + readme.WriteString("## Deployment\n\n") + readme.WriteString("### Using the provided script:\n\n") + readme.WriteString("```bash\n") + readme.WriteString("# Linux/Mac\n") + readme.WriteString("./apply.sh\n\n") + readme.WriteString("# Windows\n") + readme.WriteString("apply.bat\n") + readme.WriteString("```\n\n") + + readme.WriteString("### Manual deployment:\n\n") + readme.WriteString("```bash\n") + readme.WriteString(fmt.Sprintf("# Create namespace\n")) + readme.WriteString(fmt.Sprintf("kubectl create namespace %s\n\n", m.namespace)) + readme.WriteString("# Apply manifests\n") + readme.WriteString("kubectl apply -f configmap.yaml\n") + readme.WriteString("kubectl apply -f services.yaml\n") + readme.WriteString("kubectl apply -f deployment.yaml\n") + readme.WriteString("```\n\n") + + readme.WriteString("## Configuration\n\n") + readme.WriteString(fmt.Sprintf("- **Namespace:** %s\n", m.namespace)) + readme.WriteString(fmt.Sprintf("- **Replicas:** %d\n", m.replicas)) + readme.WriteString(fmt.Sprintf("- **Deployment Name:** %s\n\n", m.deploymentName)) + + // List required environment variables + uniqueVars := make(map[string]bool) + for _, vars := range m.passthroughEnv { + for _, v := range vars { + uniqueVars[v] = true + } + } + + if len(uniqueVars) > 0 { + readme.WriteString("## Required Environment Variables\n\n") + readme.WriteString("Set these environment variables before deploying:\n\n") + for varName := range uniqueVars { + readme.WriteString(fmt.Sprintf("- `%s`\n", varName)) + } + readme.WriteString("\n") + } + + readme.WriteString("## Monitoring\n\n") + readme.WriteString("```bash\n") + readme.WriteString(fmt.Sprintf("# Check pod status\n")) + readme.WriteString(fmt.Sprintf("kubectl get pods -n %s\n\n", m.namespace)) + readme.WriteString(fmt.Sprintf("# View logs\n")) + readme.WriteString(fmt.Sprintf("kubectl logs -n %s -l app=%s\n\n", m.namespace, m.deploymentName)) + readme.WriteString(fmt.Sprintf("# Describe deployment\n")) + readme.WriteString(fmt.Sprintf("kubectl describe deployment -n %s %s\n", m.namespace, m.deploymentName)) + readme.WriteString("```\n\n") + + readme.WriteString("## Cleanup\n\n") + readme.WriteString("```bash\n") + readme.WriteString(fmt.Sprintf("kubectl delete -f deployment.yaml\n")) + readme.WriteString(fmt.Sprintf("kubectl delete -f services.yaml\n")) + readme.WriteString(fmt.Sprintf("kubectl delete -f configmap.yaml\n")) + readme.WriteString("```\n") + + readmeFile := filepath.Join(m.outputDir, "README.md") + return os.WriteFile(readmeFile, []byte(readme.String()), 0644) +} diff --git a/plugins/kubernetes/property_test.go b/plugins/kubernetes/property_test.go new file mode 100644 index 00000000..cc9e42fc --- /dev/null +++ b/plugins/kubernetes/property_test.go @@ -0,0 +1,477 @@ +package kubernetes + +import ( + "context" + "fmt" + "math/rand" + "strings" + "testing" + "time" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/address" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// PropertyTestConfig holds configuration for property-based tests +type PropertyTestConfig struct { + Seed int64 + Iterations int + MinItems int + MaxItems int +} + +// NewPropertyTestConfig creates a default property test configuration +func NewPropertyTestConfig() *PropertyTestConfig { + return &PropertyTestConfig{ + Seed: time.Now().UnixNano(), + Iterations: 100, + MinItems: 0, + MaxItems: 10, + } +} + +// TestPropertyDeploymentAlwaysGeneratesValidYAML tests that deployments always generate valid YAML +func TestPropertyDeploymentAlwaysGeneratesValidYAML(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + // Generate random deployment + deployment := generateRandomDeployment(t, i) + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify all YAML files are valid + for path, content := range mockCtx.GetAllFiles() { + if strings.HasSuffix(path, ".yaml") { + var data interface{} + err := yaml.Unmarshal([]byte(content), &data) + assert.NoError(t, err, "Iteration %d: Invalid YAML in %s", i, path) + } + } + } +} + +// TestPropertyServiceDiscoveryConsistency tests that service discovery is consistent +func TestPropertyServiceDiscoveryConsistency(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + // Generate deployment with multiple containers + numContainers := rand.Intn(5) + 2 // 2-6 containers + deployment := createDeploymentWithContainers(t, numContainers) + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + + // Create workspace and process arg nodes + workspace := &kubernetesWorkspace{deployment: deployment} + processArgNodes(workspace, deployment.Containers) + + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify service discovery consistency + verifyServiceDiscoveryConsistency(t, deployment, i) + } +} + +// TestPropertyNamespaceHandling tests various namespace configurations +func TestPropertyNamespaceHandling(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + namespaces := []string{"", "default", "production", "staging", "dev", "test-ns-123"} + + for i := 0; i < config.Iterations; i++ { + // Pick random namespace + namespace := namespaces[rand.Intn(len(namespaces))] + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("deployment-%d", i)}, + DeploymentName: fmt.Sprintf("test-deployment-%d", i), + Namespace: namespace, + Replicas: rand.Intn(10) + 1, + Containers: []any{createRandomContainer(i)}, + } + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify namespace handling + deploymentYAML := getGeneratedFile(mockCtx, "deployment.yaml") + if namespace == "" { + assert.NotContains(t, deploymentYAML, "namespace:", "Empty namespace should not appear in YAML") + } else { + assert.Contains(t, deploymentYAML, fmt.Sprintf("namespace: %s", namespace)) + } + } +} + +// TestPropertyPortRangeValidation tests that all generated ports are in valid range +func TestPropertyPortRangeValidation(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + // Create container with random ports + numPorts := rand.Intn(5) + 1 + container := createContainerWithRandomPorts(i, numPorts) + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("deployment-%d", i)}, + DeploymentName: fmt.Sprintf("port-test-%d", i), + Namespace: "default", + Replicas: 1, + Containers: []any{container}, + } + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify all ports are in valid range + for path, content := range mockCtx.GetAllFiles() { + if strings.Contains(path, "-service.yaml") { + verifyPortsInValidRange(t, content, i) + } + } + } +} + +// TestPropertyEnvironmentVariableHandling tests environment variable generation +func TestPropertyEnvironmentVariableHandling(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + // Create container with random environment variables + numEnvVars := rand.Intn(20) + container := createContainerWithRandomEnvVars(i, numEnvVars) + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("deployment-%d", i)}, + DeploymentName: fmt.Sprintf("env-test-%d", i), + Namespace: "default", + Replicas: 1, + Containers: []any{container}, + } + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify environment variables + if numEnvVars > 0 { + configMapYAML := getGeneratedFile(mockCtx, "configmap.yaml") + assert.Contains(t, configMapYAML, "kind: ConfigMap") + + // Verify all env vars are present + for key := range container.EnvironmentVariables { + assert.Contains(t, configMapYAML, key) + } + } + } +} + +// TestPropertyReplicaCountHandling tests various replica configurations +func TestPropertyReplicaCountHandling(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + // Generate random replica count (including edge cases) + replicas := generateReplicaCount(rand.Intn(100)) + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("deployment-%d", i)}, + DeploymentName: fmt.Sprintf("replica-test-%d", i), + Namespace: "default", + Replicas: replicas, + Containers: []any{createRandomContainer(i)}, + } + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify replica count + deploymentYAML := getGeneratedFile(mockCtx, "deployment.yaml") + assert.Contains(t, deploymentYAML, fmt.Sprintf("replicas: %d", replicas)) + } +} + +// TestPropertyContainerNameUniqueness tests that container names are unique within deployment +func TestPropertyContainerNameUniqueness(t *testing.T) { + config := NewPropertyTestConfig() + rand.Seed(config.Seed) + t.Logf("Using seed: %d", config.Seed) + + for i := 0; i < config.Iterations; i++ { + numContainers := rand.Intn(10) + 1 + containers := make([]any, numContainers) + containerNames := make(map[string]bool) + + // Create containers with unique names + for j := 0; j < numContainers; j++ { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: fmt.Sprintf("container-%d-%d", i, j), + }, + ImageName: fmt.Sprintf("image-%d-%d:latest", i, j), + Ports: make(map[string]*address.BindConfig), + } + containers[j] = container + containerNames[container.Name()] = true + } + + deployment := &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("deployment-%d", i)}, + DeploymentName: fmt.Sprintf("unique-test-%d", i), + Namespace: "default", + Replicas: 1, + Containers: containers, + } + + // Generate artifacts + ctx := context.Background() + mockCtx := newMockBuildContext("/tmp/test") + err := deployment.GenerateArtifacts(ctx, mockCtx) + require.NoError(t, err, "Iteration %d: Failed to generate artifacts", i) + + // Verify uniqueness property held + assert.Equal(t, numContainers, len(containerNames)) + } +} + +// Helper functions for property-based tests + +func generateRandomDeployment(t *testing.T, iteration int) *KubernetesDeployment { + numContainers := rand.Intn(5) + 1 + containers := make([]any, numContainers) + + for i := 0; i < numContainers; i++ { + containers[i] = createRandomContainer(iteration*100 + i) + } + + return &KubernetesDeployment{ + Node: blueprint.Node{NodeName: fmt.Sprintf("random-deployment-%d", iteration)}, + DeploymentName: fmt.Sprintf("deployment-%d", iteration), + Namespace: randomNamespace(), + Replicas: rand.Intn(10) + 1, + Containers: containers, + ClusterConfig: ClusterConfiguration{ + Endpoint: fmt.Sprintf("https://k8s-%d.example.com", iteration), + Kubeconfig: fmt.Sprintf("/path/to/kubeconfig-%d", iteration), + }, + } +} + +func createRandomContainer(id int) *docker.Container { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: fmt.Sprintf("container-%d", id), + }, + ImageName: fmt.Sprintf("image-%d:v%d", id, rand.Intn(10)), + Ports: make(map[string]*address.BindConfig), + EnvironmentVariables: make(map[string]string), + } + + // Add random ports + numPorts := rand.Intn(3) + for i := 0; i < numPorts; i++ { + portName := fmt.Sprintf("port%d", i) + container.Ports[portName] = &address.BindConfig{ + Port: 8000 + rand.Intn(1000), + } + } + + // Add random env vars + numEnvVars := rand.Intn(5) + for i := 0; i < numEnvVars; i++ { + key := fmt.Sprintf("ENV_VAR_%d", i) + value := fmt.Sprintf("value_%d_%d", id, i) + container.EnvironmentVariables[key] = value + } + + return container +} + +func createDeploymentWithContainers(t *testing.T, numContainers int) *KubernetesDeployment { + containers := make([]any, numContainers) + for i := 0; i < numContainers; i++ { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: fmt.Sprintf("service%d", i), + }, + ImageName: fmt.Sprintf("service%d:latest", i), + Ports: map[string]*address.BindConfig{ + "main": {Port: 8000 + i}, + }, + EnvironmentVariables: make(map[string]string), + } + containers[i] = container + } + + return &KubernetesDeployment{ + Node: blueprint.Node{NodeName: "test-deployment"}, + DeploymentName: "test-deployment", + Namespace: "default", + Replicas: 1, + Containers: containers, + } +} + +func createContainerWithRandomPorts(id int, numPorts int) *docker.Container { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: fmt.Sprintf("container-%d", id), + }, + ImageName: fmt.Sprintf("image-%d:latest", id), + Ports: make(map[string]*address.BindConfig), + } + + for i := 0; i < numPorts; i++ { + // Generate ports in valid range (1-65535) + port := rand.Intn(65535) + 1 + portName := fmt.Sprintf("port%d", i) + container.Ports[portName] = &address.BindConfig{ + Port: port, + } + } + + return container +} + +func createContainerWithRandomEnvVars(id int, numEnvVars int) *docker.Container { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: fmt.Sprintf("container-%d", id), + }, + ImageName: fmt.Sprintf("image-%d:latest", id), + Ports: make(map[string]*address.BindConfig), + EnvironmentVariables: make(map[string]string), + } + + for i := 0; i < numEnvVars; i++ { + // Generate various types of env var names and values + key := generateEnvVarKey(i) + value := generateEnvVarValue(i) + container.EnvironmentVariables[key] = value + } + + return container +} + +func generateEnvVarKey(index int) string { + patterns := []string{ + "SIMPLE_VAR_%d", + "APP_CONFIG_%d", + "DATABASE_URL_%d", + "API_KEY_%d", + "FEATURE_FLAG_%d", + } + pattern := patterns[index%len(patterns)] + return fmt.Sprintf(pattern, index) +} + +func generateEnvVarValue(index int) string { + values := []string{ + "simple-value-%d", + "http://service-%d:8080", + "postgresql://user:pass@db-%d:5432/database", + "secret-key-%d-xxxxx", + "true", + "false", + "%d", + } + value := values[index%len(values)] + return fmt.Sprintf(value, index) +} + +func randomNamespace() string { + namespaces := []string{"default", "production", "staging", "development", "testing"} + return namespaces[rand.Intn(len(namespaces))] +} + +func generateReplicaCount(seed int) int { + // Include edge cases + edgeCases := []int{0, 1, 2, 3, 5, 10, 100} + if seed < len(edgeCases) { + return edgeCases[seed] + } + // Random value between 1 and 50 + return rand.Intn(50) + 1 +} + +func verifyServiceDiscoveryConsistency(t *testing.T, deployment *KubernetesDeployment, iteration int) { + // Each container should have env vars for all other containers with ports + for i, container := range deployment.Containers { + c := container.(*docker.Container) + for j, otherContainer := range deployment.Containers { + if i != j { + other := otherContainer.(*docker.Container) + if len(other.Ports) > 0 { + envVarName := strings.ToUpper(other.Name()) + "_ADDR" + assert.Contains(t, c.EnvironmentVariables, envVarName, + "Iteration %d: Container %s should have env var for %s", + iteration, c.Name(), other.Name()) + } + } + } + } +} + +func verifyPortsInValidRange(t *testing.T, serviceYAML string, iteration int) { + var service map[string]interface{} + err := yaml.Unmarshal([]byte(serviceYAML), &service) + require.NoError(t, err) + + spec, ok := service["spec"].(map[string]interface{}) + require.True(t, ok) + + ports, ok := spec["ports"].([]interface{}) + require.True(t, ok) + + for _, portInterface := range ports { + port := portInterface.(map[string]interface{}) + portNum, ok := port["port"].(int) + require.True(t, ok) + assert.True(t, portNum >= 1 && portNum <= 65535, + "Iteration %d: Port %d is not in valid range", iteration, portNum) + } +} + +func getGeneratedFile(ctx *mockBuildContext, suffix string) string { + for path, content := range ctx.GetAllFiles() { + if strings.HasSuffix(path, suffix) { + return content + } + } + return "" +} diff --git a/plugins/kubernetes/test_utils.go b/plugins/kubernetes/test_utils.go new file mode 100644 index 00000000..bb804dbb --- /dev/null +++ b/plugins/kubernetes/test_utils.go @@ -0,0 +1,384 @@ +package kubernetes + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/address" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/wiring" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/blueprint-uservices/blueprint/plugins/golang" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestHelpers provides utility functions for testing the Kubernetes plugin +type TestHelpers struct { + t *testing.T +} + +// NewTestHelpers creates a new instance of TestHelpers +func NewTestHelpers(t *testing.T) *TestHelpers { + return &TestHelpers{t: t} +} + +// CreateTestService creates a golang.Service for testing +func (h *TestHelpers) CreateTestService(name string, options ...ServiceOption) *golang.Service { + service := &golang.Service{ + Node: blueprint.Node{ + NodeName: name, + }, + } + + // Apply options + for _, opt := range options { + opt(service) + } + + return service +} + +// ServiceOption is a function that configures a service +type ServiceOption func(*golang.Service) + +// CreateTestContainer creates a docker.Container for testing +func (h *TestHelpers) CreateTestContainer(name, image string, options ...ContainerOption) *docker.Container { + container := &docker.Container{ + Node: blueprint.Node{ + NodeName: name, + }, + ImageName: image, + Ports: make(map[string]*address.BindConfig), + EnvironmentVariables: make(map[string]string), + } + + // Apply options + for _, opt := range options { + opt(container) + } + + return container +} + +// ContainerOption is a function that configures a container +type ContainerOption func(*docker.Container) + +// WithPort adds a port to a container +func WithPort(name string, port int) ContainerOption { + return func(c *docker.Container) { + c.Ports[name] = &address.BindConfig{ + Port: port, + } + } +} + +// WithEnvVar adds an environment variable to a container +func WithEnvVar(key, value string) ContainerOption { + return func(c *docker.Container) { + c.EnvironmentVariables[key] = value + } +} + +// WithCommand sets the command for a container +func WithCommand(command []string) ContainerOption { + return func(c *docker.Container) { + c.Command = command + } +} + +// CreateTestDeployment creates a KubernetesDeployment for testing +func (h *TestHelpers) CreateTestDeployment(name string, options ...DeploymentOption) *KubernetesDeployment { + deployment := &KubernetesDeployment{ + Node: blueprint.Node{ + NodeName: name, + }, + DeploymentName: name, + Namespace: "default", + Replicas: 1, + Containers: []any{}, + ClusterConfig: ClusterConfiguration{}, + } + + // Apply options + for _, opt := range options { + opt(deployment) + } + + return deployment +} + +// DeploymentOption is a function that configures a deployment +type DeploymentOption func(*KubernetesDeployment) + +// WithNamespace sets the namespace for a deployment +func WithNamespace(namespace string) DeploymentOption { + return func(d *KubernetesDeployment) { + d.Namespace = namespace + } +} + +// WithReplicas sets the number of replicas for a deployment +func WithReplicas(replicas int) DeploymentOption { + return func(d *KubernetesDeployment) { + d.Replicas = replicas + } +} + +// WithClusterConfig sets the cluster configuration for a deployment +func WithClusterConfig(endpoint, kubeconfig, token string) DeploymentOption { + return func(d *KubernetesDeployment) { + d.ClusterConfig = ClusterConfiguration{ + Endpoint: endpoint, + Kubeconfig: kubeconfig, + AuthToken: token, + } + } +} + +// WithContainer adds a container to a deployment +func WithContainer(container any) DeploymentOption { + return func(d *KubernetesDeployment) { + d.Containers = append(d.Containers, container) + } +} + +// CreateTestWiringSpec creates a wiring spec for testing +func (h *TestHelpers) CreateTestWiringSpec(name string) wiring.WiringSpec { + return wiring.NewWiringSpec(name) +} + +// AssertYAMLValid validates that a string contains valid YAML +func (h *TestHelpers) AssertYAMLValid(yamlContent string) { + var data interface{} + err := yaml.Unmarshal([]byte(yamlContent), &data) + require.NoError(h.t, err, "YAML content should be valid") +} + +// AssertYAMLContains checks if YAML content contains expected fields +func (h *TestHelpers) AssertYAMLContains(yamlContent string, expectedFields map[string]interface{}) { + var data map[string]interface{} + err := yaml.Unmarshal([]byte(yamlContent), &data) + require.NoError(h.t, err, "YAML content should be valid") + + for key, expectedValue := range expectedFields { + actualValue := h.getNestedValue(data, key) + require.NotNil(h.t, actualValue, "Field %s should exist in YAML", key) + require.Equal(h.t, expectedValue, actualValue, "Field %s should have expected value", key) + } +} + +// getNestedValue retrieves a nested value from a map using dot notation +func (h *TestHelpers) getNestedValue(data map[string]interface{}, path string) interface{} { + parts := strings.Split(path, ".") + current := interface{}(data) + + for _, part := range parts { + switch v := current.(type) { + case map[string]interface{}: + current = v[part] + case map[interface{}]interface{}: + current = v[part] + default: + return nil + } + } + + return current +} + +// AssertFileGenerated checks if a file was generated in the mock context +func (h *TestHelpers) AssertFileGenerated(ctx *mockBuildContext, expectedPath string) string { + content, ok := ctx.GetWrittenFile(expectedPath) + require.True(h.t, ok, "File %s should be generated", expectedPath) + return content +} + +// AssertFileNotGenerated checks if a file was not generated +func (h *TestHelpers) AssertFileNotGenerated(ctx *mockBuildContext, unexpectedPath string) { + _, ok := ctx.GetWrittenFile(unexpectedPath) + require.False(h.t, ok, "File %s should not be generated", unexpectedPath) +} + +// AssertManifestCount checks the number of manifest files generated +func (h *TestHelpers) AssertManifestCount(ctx *mockBuildContext, expectedCount int) { + manifestCount := 0 + for path := range ctx.GetAllFiles() { + if strings.Contains(path, "manifests/") && strings.HasSuffix(path, ".yaml") { + manifestCount++ + } + } + require.Equal(h.t, expectedCount, manifestCount, "Should generate expected number of manifest files") +} + +// CreateMicroserviceSetup creates a typical microservice setup for testing +func (h *TestHelpers) CreateMicroserviceSetup() (*KubernetesDeployment, map[string]*docker.Container) { + containers := make(map[string]*docker.Container) + + // Frontend service + containers["frontend"] = h.CreateTestContainer("frontend", "frontend:v1.0", + WithPort("http", 3000), + WithEnvVar("API_URL", "http://api:8080"), + WithEnvVar("NODE_ENV", "production"), + ) + + // API service + containers["api"] = h.CreateTestContainer("api", "api:v1.0", + WithPort("http", 8080), + WithEnvVar("DB_HOST", "database"), + WithEnvVar("DB_PORT", "5432"), + WithEnvVar("CACHE_HOST", "redis"), + ) + + // Database service + containers["database"] = h.CreateTestContainer("database", "postgres:13", + WithPort("postgresql", 5432), + WithEnvVar("POSTGRES_DB", "myapp"), + WithEnvVar("POSTGRES_USER", "user"), + WithEnvVar("POSTGRES_PASSWORD", "password"), + ) + + // Cache service + containers["redis"] = h.CreateTestContainer("redis", "redis:6-alpine", + WithPort("redis", 6379), + ) + + // Create deployment with all containers + deployment := h.CreateTestDeployment("microservice-app", + WithNamespace("production"), + WithReplicas(3), + WithClusterConfig("https://k8s.prod.example.com", "/etc/kubeconfig", "prod-token"), + ) + + for _, container := range containers { + deployment.Containers = append(deployment.Containers, container) + } + + return deployment, containers +} + +// ValidateServiceManifest validates a Kubernetes Service manifest +func (h *TestHelpers) ValidateServiceManifest(yamlContent string, expectedName string, expectedPort int) { + h.AssertYAMLValid(yamlContent) + h.AssertYAMLContains(yamlContent, map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata.name": expectedName, + "spec.ports.0.port": expectedPort, + }) +} + +// ValidateDeploymentManifest validates a Kubernetes Deployment manifest +func (h *TestHelpers) ValidateDeploymentManifest(yamlContent string, expectedName string, expectedReplicas int) { + h.AssertYAMLValid(yamlContent) + h.AssertYAMLContains(yamlContent, map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata.name": expectedName, + "spec.replicas": expectedReplicas, + }) +} + +// ValidateConfigMapManifest validates a Kubernetes ConfigMap manifest +func (h *TestHelpers) ValidateConfigMapManifest(yamlContent string, expectedName string) { + h.AssertYAMLValid(yamlContent) + h.AssertYAMLContains(yamlContent, map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata.name": expectedName, + }) +} + +// GenerateTestArtifacts generates artifacts and returns the mock context +func (h *TestHelpers) GenerateTestArtifacts(deployment *KubernetesDeployment, outputDir string) *mockBuildContext { + ctx := newMockBuildContext(outputDir) + err := deployment.GenerateArtifacts(nil, ctx) + require.NoError(h.t, err, "Artifact generation should succeed") + return ctx +} + +// AssertScriptContains checks if a script file contains expected content +func (h *TestHelpers) AssertScriptContains(ctx *mockBuildContext, scriptPath string, expectedContent []string) { + script := h.AssertFileGenerated(ctx, scriptPath) + for _, content := range expectedContent { + require.Contains(h.t, script, content, "Script should contain: %s", content) + } +} + +// CreateComplexDeploymentScenario creates a complex deployment scenario for integration testing +func (h *TestHelpers) CreateComplexDeploymentScenario() []*KubernetesDeployment { + deployments := []*KubernetesDeployment{} + + // User-facing services deployment + userServices := h.CreateTestDeployment("user-services", + WithNamespace("frontend"), + WithReplicas(5), + WithContainer(h.CreateTestContainer("web", "nginx:latest", + WithPort("http", 80), + WithPort("https", 443), + )), + WithContainer(h.CreateTestContainer("app", "app:v2.0", + WithPort("http", 8080), + WithEnvVar("CONFIG_SERVER", "http://config:8888"), + )), + ) + deployments = append(deployments, userServices) + + // Backend services deployment + backendServices := h.CreateTestDeployment("backend-services", + WithNamespace("backend"), + WithReplicas(3), + WithContainer(h.CreateTestContainer("api", "api:v2.0", + WithPort("http", 8080), + WithPort("grpc", 9090), + )), + WithContainer(h.CreateTestContainer("worker", "worker:v2.0", + WithEnvVar("QUEUE_URL", "amqp://rabbitmq:5672"), + )), + ) + deployments = append(deployments, backendServices) + + // Data layer deployment + dataLayer := h.CreateTestDeployment("data-layer", + WithNamespace("data"), + WithReplicas(1), + WithContainer(h.CreateTestContainer("postgres", "postgres:13", + WithPort("postgresql", 5432), + )), + WithContainer(h.CreateTestContainer("redis", "redis:6", + WithPort("redis", 6379), + )), + WithContainer(h.CreateTestContainer("elasticsearch", "elasticsearch:7.10", + WithPort("http", 9200), + WithPort("transport", 9300), + )), + ) + deployments = append(deployments, dataLayer) + + return deployments +} + +// CompareYAMLStructure compares two YAML structures for equality +func (h *TestHelpers) CompareYAMLStructure(yaml1, yaml2 string) bool { + var data1, data2 interface{} + err1 := yaml.Unmarshal([]byte(yaml1), &data1) + err2 := yaml.Unmarshal([]byte(yaml2), &data2) + + if err1 != nil || err2 != nil { + return false + } + + return fmt.Sprintf("%v", data1) == fmt.Sprintf("%v", data2) +} + +// GetManifestPath returns the expected path for a manifest file +func (h *TestHelpers) GetManifestPath(outputDir, filename string) string { + return filepath.Join(outputDir, "kubernetes", "manifests", filename) +} + +// GetScriptPath returns the expected path for a script file +func (h *TestHelpers) GetScriptPath(outputDir, filename string) string { + return filepath.Join(outputDir, "kubernetes", filename) +} diff --git a/plugins/kubernetes/wiring.go b/plugins/kubernetes/wiring.go new file mode 100644 index 00000000..c1a47c01 --- /dev/null +++ b/plugins/kubernetes/wiring.go @@ -0,0 +1,151 @@ +// Package kubernetes is a plugin for deploying container instances to a Kubernetes cluster. +// +// # Wiring Spec Usage +// +// To use the kubernetes plugin in your wiring spec, you can declare a deployment, giving it a name and +// specifying which container instances to include: +// +// kubernetes.NewDeployment(spec, "my_deployment", "container1", "container2") +// +// You can add containers to existing deployments: +// +// kubernetes.AddContainerToDeployment(spec, "my_deployment", "container3") +// +// You can configure the Kubernetes namespace: +// +// kubernetes.SetNamespace(spec, "my_deployment", "production") +// +// You can set the number of replicas: +// +// kubernetes.SetReplicas(spec, "my_deployment", 3) +// +// You can provide cluster configuration: +// +// config := &kubernetes.ClusterConfiguration{ +// KubeconfigPath: "/path/to/kubeconfig", +// Namespace: "my-namespace", +// } +// kubernetes.ConfigureCluster(spec, "my_deployment", config) +// +// # Artifacts Generated +// +// During compilation, the plugin generates Kubernetes YAML manifests including: +// - Deployment resources for container instances +// - Service resources for inter-container networking +// - ConfigMaps for environment variables +// - An apply.sh script to deploy all resources +// +// # Running Artifacts +// +// The generated artifacts can be deployed using kubectl: +// +// kubectl apply -f deployment.yaml +// kubectl apply -f services.yaml +// kubectl apply -f configmap.yaml +// +// Or use the generated script: +// +// ./apply.sh +// +// You will need to provide cluster credentials either via: +// - KUBECONFIG environment variable +// - ~/.kube/config file +// - Explicit configuration in the wiring spec +package kubernetes + +import ( + "github.com/blueprint-uservices/blueprint/blueprint/pkg/coreplugins/namespaceutil" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/wiring" + "github.com/blueprint-uservices/blueprint/plugins/docker" +) + +// AddContainerToDeployment can be used by wiring specs to add a container instance to an existing +// Kubernetes deployment. +func AddContainerToDeployment(spec wiring.WiringSpec, deploymentName, containerName string) { + namespaceutil.AddNodeTo[KubernetesDeployment](spec, deploymentName, containerName) +} + +// NewDeployment can be used by wiring specs to create a Kubernetes deployment that instantiates +// a number of containers. +// +// Further container instances can be added to the deployment by calling [AddContainerToDeployment]. +// +// During compilation, generates Kubernetes YAML manifests that deploy the containers. +// +// Returns deploymentName. +func NewDeployment(spec wiring.WiringSpec, deploymentName string, containers ...string) string { + // If any containers were provided in this call, add them to the deployment + for _, containerName := range containers { + AddContainerToDeployment(spec, deploymentName, containerName) + } + + spec.Define(deploymentName, &KubernetesDeployment{}, func(namespace wiring.Namespace) (ir.IRNode, error) { + deployment := &KubernetesDeployment{ + DeploymentName: deploymentName, + Namespace: "default", + Replicas: 1, + } + _, err := namespaceutil.InstantiateNamespace(namespace, &kubernetesNamespace{deployment}) + return deployment, err + }) + + return deploymentName +} + +// SetNamespace configures the Kubernetes namespace for a deployment. +// If not set, defaults to "default". +func SetNamespace(spec wiring.WiringSpec, deploymentName string, namespace string) { + spec.Alter(deploymentName, func(ir ir.IRNode) error { + if deployment, ok := ir.(*KubernetesDeployment); ok { + deployment.SetNamespace(namespace) + } + return nil + }) +} + +// SetReplicas configures the number of replicas for a deployment. +// If not set, defaults to 1. +func SetReplicas(spec wiring.WiringSpec, deploymentName string, replicas int32) { + spec.Alter(deploymentName, func(ir ir.IRNode) error { + if deployment, ok := ir.(*KubernetesDeployment); ok { + deployment.SetReplicas(replicas) + } + return nil + }) +} + +// ConfigureCluster provides cluster configuration for a deployment. +// This configuration can include kubeconfig path, API server endpoint, authentication token, etc. +// These values can also be provided at runtime through environment variables or command-line flags. +func ConfigureCluster(spec wiring.WiringSpec, deploymentName string, config *ClusterConfiguration) { + spec.Alter(deploymentName, func(ir ir.IRNode) error { + if deployment, ok := ir.(*KubernetesDeployment); ok { + deployment.SetClusterConfig(config) + } + return nil + }) +} + +// A [wiring.NamespaceHandler] used to build Kubernetes deployments +type kubernetesNamespace struct { + *KubernetesDeployment +} + +// Implements [wiring.NamespaceHandler] +func (deployment *KubernetesDeployment) Accepts(nodeType any) bool { + _, isDockerContainerNode := nodeType.(docker.Container) + return isDockerContainerNode +} + +// Implements [wiring.NamespaceHandler] +func (deployment *KubernetesDeployment) AddEdge(name string, edge ir.IRNode) error { + deployment.Edges = append(deployment.Edges, edge) + return nil +} + +// Implements [wiring.NamespaceHandler] +func (deployment *KubernetesDeployment) AddNode(name string, node ir.IRNode) error { + deployment.Nodes = append(deployment.Nodes, node) + return nil +} diff --git a/plugins/kubernetes/wiring_test.go b/plugins/kubernetes/wiring_test.go new file mode 100644 index 00000000..41345702 --- /dev/null +++ b/plugins/kubernetes/wiring_test.go @@ -0,0 +1,334 @@ +package kubernetes + +import ( + "testing" + + "github.com/blueprint-uservices/blueprint/blueprint/pkg/blueprint/logging" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/ir" + "github.com/blueprint-uservices/blueprint/blueprint/pkg/wiring" + "github.com/blueprint-uservices/blueprint/plugins/docker" + "github.com/blueprint-uservices/blueprint/plugins/golang" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper function to create a new wiring spec for tests +func newTestWiringSpec(name string) wiring.WiringSpec { + // Disable compiler logging for tests unless explicitly enabled + logging.DisableCompilerLogging() + spec := wiring.NewWiringSpec(name) + return spec +} + +// Helper to build IR and return result +func buildIR(t *testing.T, spec wiring.WiringSpec, toInstantiate ...string) (*ir.ApplicationNode, error) { + return spec.BuildIR(toInstantiate...) +} + +func TestNewDeployment(t *testing.T) { + spec := newTestWiringSpec("TestNewDeployment") + + deploymentName := "test-app" + deployment := NewDeployment(spec, deploymentName) + + // Build IR + app, err := buildIR(t, spec, deploymentName) + require.NoError(t, err) + require.NotNil(t, app) + + // Find the deployment node + deploymentNode := app.GetChildren()[deploymentName] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, deploymentName, k8sDeployment.DeploymentName) + assert.Equal(t, deploymentName, deployment) +} + +func TestAddContainerToDeployment(t *testing.T) { + spec := newTestWiringSpec("TestAddContainerToDeployment") + + // Create a golang service + service := golang.Service(spec, "myservice") + + // Create deployment and add service + deployment := NewDeployment(spec, "test-deployment") + AddContainerToDeployment(spec, deployment, service) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Find deployment node + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Len(t, k8sDeployment.Containers, 1) +} + +func TestAddMultipleContainersToDeployment(t *testing.T) { + spec := newTestWiringSpec("TestAddMultipleContainers") + + // Create multiple services + service1 := golang.Service(spec, "service1") + service2 := golang.Service(spec, "service2") + service3 := golang.Service(spec, "service3") + + // Create deployment and add all services + deployment := NewDeployment(spec, "multi-container-app") + AddContainerToDeployment(spec, deployment, service1) + AddContainerToDeployment(spec, deployment, service2) + AddContainerToDeployment(spec, deployment, service3) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Find deployment node + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Len(t, k8sDeployment.Containers, 3) +} + +func TestSetNamespace(t *testing.T) { + spec := newTestWiringSpec("TestSetNamespace") + + deployment := NewDeployment(spec, "test-app") + SetNamespace(spec, deployment, "production") + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Find deployment node + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "production", k8sDeployment.Namespace) +} + +func TestSetReplicas(t *testing.T) { + spec := newTestWiringSpec("TestSetReplicas") + + deployment := NewDeployment(spec, "test-app") + SetReplicas(spec, deployment, 5) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Find deployment node + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, 5, k8sDeployment.Replicas) +} + +func TestConfigureCluster(t *testing.T) { + spec := newTestWiringSpec("TestConfigureCluster") + + deployment := NewDeployment(spec, "test-app") + ConfigureCluster(spec, deployment, "https://k8s.example.com", "/path/to/kubeconfig", "auth-token-123") + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Find deployment node + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "https://k8s.example.com", k8sDeployment.ClusterConfig.Endpoint) + assert.Equal(t, "/path/to/kubeconfig", k8sDeployment.ClusterConfig.Kubeconfig) + assert.Equal(t, "auth-token-123", k8sDeployment.ClusterConfig.AuthToken) +} + +func TestCompleteDeploymentConfiguration(t *testing.T) { + spec := newTestWiringSpec("TestCompleteConfiguration") + + // Create services + frontend := golang.Service(spec, "frontend") + backend := golang.Service(spec, "backend") + + // Create and configure deployment + deployment := NewDeployment(spec, "full-app") + AddContainerToDeployment(spec, deployment, frontend) + AddContainerToDeployment(spec, deployment, backend) + SetNamespace(spec, deployment, "staging") + SetReplicas(spec, deployment, 3) + ConfigureCluster(spec, deployment, "https://staging.k8s.local", "", "staging-token") + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Verify complete configuration + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "full-app", k8sDeployment.DeploymentName) + assert.Equal(t, "staging", k8sDeployment.Namespace) + assert.Equal(t, 3, k8sDeployment.Replicas) + assert.Len(t, k8sDeployment.Containers, 2) + assert.Equal(t, "https://staging.k8s.local", k8sDeployment.ClusterConfig.Endpoint) + assert.Equal(t, "staging-token", k8sDeployment.ClusterConfig.AuthToken) +} + +func TestNamespaceHandler(t *testing.T) { + spec := newTestWiringSpec("TestNamespaceHandler") + + // Create a Kubernetes namespace using Define + k8sNs := spec.Define("kubernetes", "k8s-app", namespaceHandler) + + // Create services + service1 := golang.Service(spec, "service1") + service2 := golang.Service(spec, "service2") + + // Place services in the Kubernetes namespace + k8sNs.Place(service1) + k8sNs.Place(service2) + + // Configure the deployment + deployment := k8sNs.Instantiate() + SetNamespace(spec, deployment, "k8s-namespace") + SetReplicas(spec, deployment, 2) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Verify namespace created a deployment with the services + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "k8s-app", k8sDeployment.DeploymentName) + assert.Equal(t, "k8s-namespace", k8sDeployment.Namespace) + assert.Equal(t, 2, k8sDeployment.Replicas) + assert.Len(t, k8sDeployment.Containers, 2) +} + +func TestAddContainerWithDockerContainer(t *testing.T) { + spec := newTestWiringSpec("TestDockerContainer") + + // Create a docker container + container := docker.Container(spec, "redis") + + // Create deployment and add container + deployment := NewDeployment(spec, "test-deployment") + AddContainerToDeployment(spec, deployment, container) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Verify container was added + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Len(t, k8sDeployment.Containers, 1) +} + +func TestEmptyDeployment(t *testing.T) { + spec := newTestWiringSpec("TestEmptyDeployment") + + // Create deployment without adding any containers + deployment := NewDeployment(spec, "empty-deployment") + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Verify empty deployment + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "empty-deployment", k8sDeployment.DeploymentName) + assert.Empty(t, k8sDeployment.Containers) + assert.Equal(t, "", k8sDeployment.Namespace) // Default empty + assert.Equal(t, 0, k8sDeployment.Replicas) // Default 0 +} + +func TestDefaultValues(t *testing.T) { + spec := newTestWiringSpec("TestDefaultValues") + + // Create deployment with minimal configuration + deployment := NewDeployment(spec, "minimal-app") + service := golang.Service(spec, "myservice") + AddContainerToDeployment(spec, deployment, service) + + // Build IR + app, err := buildIR(t, spec, deployment) + require.NoError(t, err) + + // Verify defaults + deploymentNode := app.GetChildren()[deployment] + require.NotNil(t, deploymentNode) + + k8sDeployment, ok := deploymentNode.(*KubernetesDeployment) + require.True(t, ok) + assert.Equal(t, "", k8sDeployment.Namespace) // Empty namespace (will default to "default" in manifest) + assert.Equal(t, 0, k8sDeployment.Replicas) // 0 replicas (will default to 1 in manifest) + assert.Equal(t, "", k8sDeployment.ClusterConfig.Endpoint) // Empty endpoint + assert.Equal(t, "", k8sDeployment.ClusterConfig.Kubeconfig) // Empty kubeconfig + assert.Equal(t, "", k8sDeployment.ClusterConfig.AuthToken) // Empty token +} + +func TestMultipleDeployments(t *testing.T) { + spec := newTestWiringSpec("TestMultipleDeployments") + + // Create multiple deployments + deployment1 := NewDeployment(spec, "app1") + deployment2 := NewDeployment(spec, "app2") + + service1 := golang.Service(spec, "service1") + service2 := golang.Service(spec, "service2") + + AddContainerToDeployment(spec, deployment1, service1) + AddContainerToDeployment(spec, deployment2, service2) + + SetNamespace(spec, deployment1, "namespace1") + SetNamespace(spec, deployment2, "namespace2") + + // Build IR for both deployments + app, err := buildIR(t, spec, deployment1, deployment2) + require.NoError(t, err) + + // Verify both deployments exist + deployment1Node := app.GetChildren()[deployment1] + deployment2Node := app.GetChildren()[deployment2] + require.NotNil(t, deployment1Node) + require.NotNil(t, deployment2Node) + + k8sDeployment1, ok1 := deployment1Node.(*KubernetesDeployment) + k8sDeployment2, ok2 := deployment2Node.(*KubernetesDeployment) + require.True(t, ok1) + require.True(t, ok2) + + assert.Equal(t, "app1", k8sDeployment1.DeploymentName) + assert.Equal(t, "namespace1", k8sDeployment1.Namespace) + assert.Len(t, k8sDeployment1.Containers, 1) + + assert.Equal(t, "app2", k8sDeployment2.DeploymentName) + assert.Equal(t, "namespace2", k8sDeployment2.Namespace) + assert.Len(t, k8sDeployment2.Containers, 1) +}