From 8ff9d98136843f62c5b6ab0cb6c623655f6c3e6d Mon Sep 17 00:00:00 2001 From: fnerdman Date: Wed, 30 Apr 2025 15:40:39 +0200 Subject: [PATCH] Add Kubernetes manifest generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created k8s_generator.go with Kubernetes manifest generation logic - Added CLI flags for Kubernetes manifest generation - Integrated manifest generation into recipe workflow - Added CRD defintion to examples folder 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- examples/BuilderPlayground-CRD-Definition.yml | 241 +++++++++++ internal/k8s_generator.go | 397 ++++++++++++++++++ main.go | 28 ++ 3 files changed, 666 insertions(+) create mode 100644 examples/BuilderPlayground-CRD-Definition.yml create mode 100644 internal/k8s_generator.go diff --git a/examples/BuilderPlayground-CRD-Definition.yml b/examples/BuilderPlayground-CRD-Definition.yml new file mode 100644 index 0000000..fded044 --- /dev/null +++ b/examples/BuilderPlayground-CRD-Definition.yml @@ -0,0 +1,241 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: builderplaygrounddeployments.playground.flashbots.io +spec: + group: playground.flashbots.io + names: + kind: BuilderPlaygroundDeployment + plural: builderplaygrounddeployments + singular: builderplaygrounddeployment + shortNames: + - bpd + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + required: + - spec + properties: + spec: + type: object + required: + - services + properties: + # Recipe/origin information + recipe: + type: string + description: "The recipe used to generate this deployment (e.g., l1, opstack)" + + # Storage configuration for the deployment + storage: + type: object + description: "Storage configuration for the deployment" + properties: + type: + type: string + enum: [local-path, pvc] + description: "Storage type (local-path for development, pvc for cluster deployments)" + path: + type: string + description: "Path on host for local-path storage" + storageClass: + type: string + description: "StorageClass to use for PVC (if type is pvc)" + size: + type: string + description: "Size of storage (if type is pvc)" + + # Network configuration + network: + type: object + description: "Network configuration" + properties: + name: + type: string + description: "Network name, similar to docker-compose network" + + # Service definitions + services: + type: array + description: "List of services in this deployment" + items: + type: object + required: + - name + - image + properties: + name: + type: string + description: "Service name" + + image: + type: string + description: "Container image for the service" + + tag: + type: string + description: "Image tag" + + entrypoint: + type: array + description: "Container entrypoint command" + items: + type: string + + args: + type: array + description: "Container command arguments" + items: + type: string + + env: + type: object + description: "Environment variables" + additionalProperties: + type: string + + ports: + type: array + description: "Ports exposed by the service" + items: + type: object + required: + - name + - port + properties: + name: + type: string + description: "Port name" + port: + type: integer + description: "Container port" + protocol: + type: string + enum: [tcp, udp] + default: tcp + description: "Port protocol" + hostPort: + type: integer + description: "Port on host machine (if applicable)" + + dependencies: + type: array + description: "Service dependencies" + items: + type: object + required: + - name + properties: + name: + type: string + description: "Name of the dependent service" + condition: + type: string + enum: [running, healthy] + default: running + description: "Condition for dependency" + + readyCheck: + type: object + description: "Service readiness check" + properties: + queryURL: + type: string + description: "URL to query for readiness" + test: + type: array + items: + type: string + description: "Command to run for readiness test" + interval: + type: string + description: "Interval between readiness checks" + timeout: + type: string + description: "Timeout for readiness checks" + retries: + type: integer + description: "Number of retries for readiness checks" + startPeriod: + type: string + description: "Initial delay before starting readiness checks" + + labels: + type: object + description: "Service labels" + additionalProperties: + type: string + + useHostExecution: + type: boolean + description: "Whether to run the service on the host instead of in k8s" + default: false + + volumes: + type: array + description: "Volume mounts for the service" + items: + type: object + required: + - name + - mountPath + properties: + name: + type: string + description: "Volume name" + mountPath: + type: string + description: "Path in container to mount volume" + subPath: + type: string + description: "Sub-path within the volume to mount" + + # Status section for the operator to update + status: + type: object + properties: + phase: + type: string + description: "Deployment phase (Pending, Running, Failed, etc.)" + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time + serviceStatuses: + type: object + additionalProperties: + type: object + properties: + ready: + type: boolean + status: + type: string + logs: + type: string + description: "Path to service logs" + subresources: + status: {} + additionalPrinterColumns: + - name: Status + type: string + jsonPath: .status.phase + - name: Age + type: date + jsonPath: .metadata.creationTimestamp diff --git a/internal/k8s_generator.go b/internal/k8s_generator.go new file mode 100644 index 0000000..6b1d047 --- /dev/null +++ b/internal/k8s_generator.go @@ -0,0 +1,397 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +// K8sGenerator handles creation of Kubernetes manifests +type K8sGenerator struct { + Manifest *Manifest + RecipeName string + StorageType string + StoragePath string + StorageClass string + StorageSize string + NetworkName string + OutputDir string +} + +// NewK8sGenerator creates a new Kubernetes manifest generator +func NewK8sGenerator(manifest *Manifest, recipeName string, outputDir string) *K8sGenerator { + return &K8sGenerator{ + Manifest: manifest, + RecipeName: recipeName, + StorageType: "local-path", + StoragePath: "/data/builder-playground", + StorageClass: "standard", + StorageSize: "10Gi", + OutputDir: outputDir, + } +} + +// Generate creates a Kubernetes manifest and writes it to disk +func (g *K8sGenerator) Generate() error { + // Generate the CRD + crd, err := g.buildCRD() + if err != nil { + return fmt.Errorf("failed to build CRD: %w", err) + } + + // Marshal to YAML + yamlData, err := yaml.Marshal(crd) + if err != nil { + return fmt.Errorf("failed to marshal CRD to YAML: %w", err) + } + + // Write to file + outputPath := filepath.Join(g.OutputDir, "k8s-manifest.yaml") + if err := os.WriteFile(outputPath, yamlData, 0644); err != nil { + return fmt.Errorf("failed to write manifest to %s: %w", outputPath, err) + } + + return nil +} + +// BuilderPlaygroundDeployment represents the top-level K8s CRD +type BuilderPlaygroundDeployment struct { + // APIVersion is the Kubernetes API version for this resource + APIVersion string `yaml:"apiVersion"` + // Kind identifies this as a BuilderPlaygroundDeployment + Kind string `yaml:"kind"` + // Metadata contains the resource metadata + Metadata BuilderPlaygroundMetadata `yaml:"metadata"` + // Spec defines the desired state of the deployment + Spec BuilderPlaygroundSpec `yaml:"spec"` +} + +// BuilderPlaygroundMetadata contains the resource metadata +type BuilderPlaygroundMetadata struct { + // Name is the name of the deployment + Name string `yaml:"name"` +} + +// BuilderPlaygroundSpec defines the desired state of the deployment +type BuilderPlaygroundSpec struct { + // Recipe is the builder-playground recipe used (l1, opstack, etc) + Recipe string `yaml:"recipe"` + // Storage defines how persistent data should be stored + Storage BuilderPlaygroundStorage `yaml:"storage"` + // Network defines networking configuration (optional) + Network *BuilderPlaygroundNetwork `yaml:"network,omitempty"` + // Services is the list of services in this deployment + Services []BuilderPlaygroundService `yaml:"services"` +} + +// BuilderPlaygroundStorage defines storage configuration +type BuilderPlaygroundStorage struct { + // Type is the storage type, either "local-path" or "pvc" + Type string `yaml:"type"` + // Path is the host path for local-path storage (used when type is "local-path") + Path string `yaml:"path,omitempty"` + // StorageClass is the K8s storage class (used when type is "pvc") + StorageClass string `yaml:"storageClass,omitempty"` + // Size is the storage size (used when type is "pvc") + Size string `yaml:"size,omitempty"` +} + +// BuilderPlaygroundNetwork defines network configuration +type BuilderPlaygroundNetwork struct { + // Name is the name of the network + Name string `yaml:"name"` +} + +// BuilderPlaygroundService represents a single service in the deployment +type BuilderPlaygroundService struct { + // Name is the service name + Name string `yaml:"name"` + // Image is the container image + Image string `yaml:"image"` + // Tag is the container image tag + Tag string `yaml:"tag"` + // Entrypoint overrides the container entrypoint + Entrypoint []string `yaml:"entrypoint,omitempty"` + // Args are the container command arguments + Args []string `yaml:"args,omitempty"` + // Env defines environment variables + Env map[string]string `yaml:"env,omitempty"` + // Ports are the container ports to expose + Ports []BuilderPlaygroundPort `yaml:"ports,omitempty"` + // Dependencies defines services this service depends on + Dependencies []BuilderPlaygroundDependency `yaml:"dependencies,omitempty"` + // ReadyCheck defines how to determine service readiness + ReadyCheck *BuilderPlaygroundReadyCheck `yaml:"readyCheck,omitempty"` + // Labels are the service labels + Labels map[string]string `yaml:"labels,omitempty"` + // UseHostExecution indicates whether to run on host instead of in container + UseHostExecution bool `yaml:"useHostExecution,omitempty"` + // Volumes are the volume mounts for the service + Volumes []BuilderPlaygroundVolume `yaml:"volumes,omitempty"` +} + +// BuilderPlaygroundPort represents a port configuration +type BuilderPlaygroundPort struct { + // Name is a unique identifier for this port + Name string `yaml:"name"` + // Port is the container port number + Port int `yaml:"port"` + // Protocol is either "tcp" or "udp" + Protocol string `yaml:"protocol,omitempty"` + // HostPort is the port to expose on the host (if applicable) + HostPort int `yaml:"hostPort,omitempty"` +} + +// BuilderPlaygroundDependency represents a service dependency +type BuilderPlaygroundDependency struct { + // Name is the name of the dependent service + Name string `yaml:"name"` + // Condition is either "running" or "healthy" + Condition string `yaml:"condition"` +} + +// BuilderPlaygroundReadyCheck defines readiness checking +type BuilderPlaygroundReadyCheck struct { + // QueryURL is the URL to query for readiness + QueryURL string `yaml:"queryURL,omitempty"` + // Test is the command to run for readiness check + Test []string `yaml:"test,omitempty"` + // Interval is the time between checks + Interval string `yaml:"interval,omitempty"` + // Timeout is the maximum time for a check + Timeout string `yaml:"timeout,omitempty"` + // Retries is the number of retry attempts + Retries int `yaml:"retries,omitempty"` + // StartPeriod is the initial delay before checks begin + StartPeriod string `yaml:"startPeriod,omitempty"` +} + +// BuilderPlaygroundVolume represents a volume mount +type BuilderPlaygroundVolume struct { + // Name is the volume name + Name string `yaml:"name"` + // MountPath is the path in the container + MountPath string `yaml:"mountPath"` + // SubPath is the path within the volume (optional) + SubPath string `yaml:"subPath,omitempty"` +} + +// buildCRD creates the CRD structure +func (g *K8sGenerator) buildCRD() (BuilderPlaygroundDeployment, error) { + crd := BuilderPlaygroundDeployment{ + APIVersion: "playground.flashbots.io/v1alpha1", + Kind: "BuilderPlaygroundDeployment", + Metadata: BuilderPlaygroundMetadata{ + Name: "builder-playground-" + g.RecipeName, + }, + Spec: BuilderPlaygroundSpec{ + Recipe: g.RecipeName, + Storage: BuilderPlaygroundStorage{ + Type: g.StorageType, + }, + Services: []BuilderPlaygroundService{}, + }, + } + + // Configure storage based on type + if g.StorageType == "local-path" { + crd.Spec.Storage.Path = g.StoragePath + } else if g.StorageType == "pvc" { + crd.Spec.Storage.StorageClass = g.StorageClass + crd.Spec.Storage.Size = g.StorageSize + } + + // Add network configuration if available + if g.NetworkName != "" { + crd.Spec.Network = &BuilderPlaygroundNetwork{ + Name: g.NetworkName, + } + } + + // Convert services + for _, svc := range g.Manifest.Services() { + k8sService, err := convertServiceToK8s(svc) + if err != nil { + return crd, fmt.Errorf("failed to convert service %s: %w", svc.Name, err) + } + crd.Spec.Services = append(crd.Spec.Services, k8sService) + } + + return crd, nil +} + +// Define internal labels that should be filtered out +var internalLabels = map[string]bool{ + "service": true, + "playground": true, + "playground.session": true, +} + +// convertServiceToK8s converts a service to Kubernetes representation +func convertServiceToK8s(svc *service) (BuilderPlaygroundService, error) { + // Validate required fields + if svc.image == "" { + return BuilderPlaygroundService{}, fmt.Errorf("service %s missing required image", svc.Name) + } + + k8sService := BuilderPlaygroundService{ + Name: svc.Name, + Image: svc.image, + Tag: svc.tag, + } + + // Convert entrypoint + if svc.entrypoint != "" { + k8sService.Entrypoint = []string{svc.entrypoint} + } + + // Convert args + if len(svc.args) > 0 { + k8sService.Args = svc.args + } + + // Convert env variables + if len(svc.env) > 0 { + k8sService.Env = make(map[string]string) + for k, v := range svc.env { + k8sService.Env[k] = v + } + } + + // Convert ports + if len(svc.ports) > 0 { + k8sPorts := make([]BuilderPlaygroundPort, 0, len(svc.ports)) + for _, port := range svc.ports { + k8sPort := BuilderPlaygroundPort{ + Name: port.Name, + Port: port.Port, + Protocol: port.Protocol, + } + if port.HostPort != 0 { + k8sPort.HostPort = port.HostPort + } + k8sPorts = append(k8sPorts, k8sPort) + } + k8sService.Ports = k8sPorts + } + + // Convert dependencies + if len(svc.dependsOn) > 0 { + k8sDeps := make([]BuilderPlaygroundDependency, 0, len(svc.dependsOn)) + for _, dep := range svc.dependsOn { + condition := "running" + if dep.Condition == DependsOnConditionHealthy { + condition = "healthy" + } + + k8sDep := BuilderPlaygroundDependency{ + Name: dep.Name, + Condition: condition, + } + k8sDeps = append(k8sDeps, k8sDep) + } + k8sService.Dependencies = k8sDeps + } + + // Convert readiness check + if svc.readyCheck != nil { + k8sReadyCheck := &BuilderPlaygroundReadyCheck{} + + if svc.readyCheck.QueryURL != "" { + k8sReadyCheck.QueryURL = svc.readyCheck.QueryURL + } + + if len(svc.readyCheck.Test) > 0 { + k8sReadyCheck.Test = svc.readyCheck.Test + } + + if svc.readyCheck.Interval != 0 { + k8sReadyCheck.Interval = svc.readyCheck.Interval.String() + } + + if svc.readyCheck.Timeout != 0 { + k8sReadyCheck.Timeout = svc.readyCheck.Timeout.String() + } + + if svc.readyCheck.Retries != 0 { + k8sReadyCheck.Retries = svc.readyCheck.Retries + } + + if svc.readyCheck.StartPeriod != 0 { + k8sReadyCheck.StartPeriod = svc.readyCheck.StartPeriod.String() + } + + // Only add the ready check if at least one field is set + if k8sReadyCheck.QueryURL != "" || len(k8sReadyCheck.Test) > 0 { + k8sService.ReadyCheck = k8sReadyCheck + } + } + + // Convert labels + if len(svc.labels) > 0 { + serviceLabels := make(map[string]string) + + for k, v := range svc.labels { + // Skip internal labels + if !internalLabels[k] { + serviceLabels[k] = v + } + } + + // Check for use-host-execution + if useHost, ok := svc.labels["use-host-execution"]; ok && useHost == "true" { + k8sService.UseHostExecution = true + } + + if len(serviceLabels) > 0 { + k8sService.Labels = serviceLabels + } + } + + // Add standard artifacts volume + k8sService.Volumes = []BuilderPlaygroundVolume{ + { + Name: "artifacts", + MountPath: "/artifacts", + }, + } + + return k8sService, nil +} + +// GenerateK8sManifest creates a Kubernetes manifest from a builder-playground manifest +// This is the main entry point for integration with the codebase +func GenerateK8sManifest(manifest *Manifest, recipeName string, output *output, storageType string, storageParams map[string]string) error { + // Get absolute output directory path + outputDir, err := output.AbsoluteDstPath() + if err != nil { + return fmt.Errorf("failed to get absolute output directory path: %w", err) + } + + generator := NewK8sGenerator(manifest, recipeName, outputDir) + + // Configure storage settings + generator.StorageType = storageType + + if storageType == "local-path" { + if path, ok := storageParams["path"]; ok { + generator.StoragePath = path + } + } else if storageType == "pvc" { + if class, ok := storageParams["class"]; ok { + generator.StorageClass = class + } + if size, ok := storageParams["size"]; ok { + generator.StorageSize = size + } + } + + // Set network name if provided + if netName, ok := storageParams["network"]; ok { + generator.NetworkName = netName + } + + return generator.Generate() +} \ No newline at end of file diff --git a/main.go b/main.go index 60d749b..5de1c64 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,11 @@ var logLevelFlag string var bindExternal bool var withPrometheus bool var networkName string +var k8sFlag bool +var storageType string +var storagePath string +var storageClass string +var storageSize string var rootCmd = &cobra.Command{ Use: "playground", @@ -173,6 +178,11 @@ func main() { recipeCmd.Flags().BoolVar(&bindExternal, "bind-external", false, "bind host ports to external interface") recipeCmd.Flags().BoolVar(&withPrometheus, "with-prometheus", false, "whether to gather the Prometheus metrics") recipeCmd.Flags().StringVar(&networkName, "network", "", "network name") + recipeCmd.Flags().BoolVar(&k8sFlag, "k8s", false, "Generate Kubernetes manifests") + recipeCmd.Flags().StringVar(&storageType, "storage-type", "local-path", "Storage type for k8s: local-path or pvc") + recipeCmd.Flags().StringVar(&storagePath, "storage-path", "/data/builder-playground", "Path for local-path storage type") + recipeCmd.Flags().StringVar(&storageClass, "storage-class", "standard", "Storage class for pvc storage type") + recipeCmd.Flags().StringVar(&storageSize, "storage-size", "10Gi", "Storage size for pvc storage type") cookCmd.AddCommand(recipeCmd) } @@ -229,6 +239,24 @@ func runIt(recipe internal.Recipe) error { return err } + // Generate Kubernetes manifests if --k8s flag is set + if k8sFlag { + storageParams := map[string]string{ + "path": storagePath, + "class": storageClass, + "size": storageSize, + "network": networkName, + } + + if err := internal.GenerateK8sManifest(svcManager, recipe.Name(), artifacts.Out, + storageType, storageParams); err != nil { + return fmt.Errorf("failed to generate Kubernetes manifest: %w", err) + } + + outputDir, _ := artifacts.Out.AbsoluteDstPath() + fmt.Printf("Kubernetes manifest generated at %s/k8s-manifest.yaml\n", outputDir) + } + if withPrometheus { if err := internal.CreatePrometheusServices(svcManager, artifacts.Out); err != nil { return fmt.Errorf("failed to create prometheus services: %w", err)