diff --git a/flagd/Dockerfile b/flagd/Dockerfile index ff4e14b..72df6c4 100644 --- a/flagd/Dockerfile +++ b/flagd/Dockerfile @@ -6,9 +6,11 @@ FROM golang:1.24 AS builder WORKDIR /app # Copy Go modules and dependencies -COPY launchpad/go.mod launchpad/go.sum launchpad/main.go ./ +COPY launchpad/go.mod launchpad/go.sum ./ RUN go mod download +COPY launchpad/ ./ + # Build the Go binary RUN go build -o launchpad main.go diff --git a/launchpad/.gitignore b/launchpad/.gitignore new file mode 100644 index 0000000..202127d --- /dev/null +++ b/launchpad/.gitignore @@ -0,0 +1,4 @@ +# Ignore local test files +flagd +/flags/ +/rawflags/ diff --git a/launchpad/flagd b/launchpad/flagd deleted file mode 100755 index 449f967..0000000 Binary files a/launchpad/flagd and /dev/null differ diff --git a/launchpad/handlers/http.go b/launchpad/handlers/http.go new file mode 100644 index 0000000..188c3e9 --- /dev/null +++ b/launchpad/handlers/http.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "openfeature.com/flagd-testbed/launchpad/pkg" + "strconv" +) + +// Response struct to standardize API responses +type Response struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// StartFlagdHandler starts the `flagd` process +func StartFlagdHandler(w http.ResponseWriter, r *http.Request) { + config := r.URL.Query().Get("config") + + if err := flagd.StartFlagd(config); err != nil { + respondWithJSON(w, http.StatusInternalServerError, "error", fmt.Sprintf("Failed to start flagd: %v", err)) + return + } + respondWithJSON(w, http.StatusOK, "success", "flagd started successfully") +} + +// RestartHandler stops and starts `flagd` +func RestartHandler(w http.ResponseWriter, r *http.Request) { + secondsStr := r.URL.Query().Get("seconds") + if secondsStr == "" { + secondsStr = "5" + } + + seconds, err := strconv.Atoi(secondsStr) + if err != nil || seconds < 0 { + respondWithJSON(w, http.StatusBadRequest, "error", "'seconds' must be a non-negative integer") + return + } + + flagd.RestartFlagd(seconds) + respondWithJSON(w, http.StatusOK, "success", fmt.Sprintf("flagd will restart in %d seconds", seconds)) +} + +// StopFlagdHandler stops `flagd` +func StopFlagdHandler(w http.ResponseWriter, r *http.Request) { + if err := flagd.StopFlagd(); err != nil { + respondWithJSON(w, http.StatusInternalServerError, "error", fmt.Sprintf("Failed to stop flagd: %v", err)) + return + } + respondWithJSON(w, http.StatusOK, "success", "flagd stopped successfully") +} + +// ChangeHandler triggers JSON file merging and notifies `flagd` +func ChangeHandler(w http.ResponseWriter, r *http.Request) { + if err := flagd.CombineJSONFiles(flagd.InputDir); err != nil { + respondWithJSON(w, http.StatusInternalServerError, "error", fmt.Sprintf("Failed to update JSON files: %v", err)) + return + } + + respondWithJSON(w, http.StatusOK, "success", "JSON files updated successfully") +} + +// Utility function to send JSON responses +func respondWithJSON(w http.ResponseWriter, statusCode int, status, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + response := Response{ + Status: status, + Message: message, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/launchpad/main.go b/launchpad/main.go index 8b62663..6219e70 100644 --- a/launchpad/main.go +++ b/launchpad/main.go @@ -2,362 +2,60 @@ package main import ( "context" - "encoding/json" "fmt" - "io/ioutil" "log" "net/http" "os" - "os/exec" "os/signal" - "path/filepath" - "strconv" - "sync" "syscall" "time" - "github.com/fsnotify/fsnotify" + "openfeature.com/flagd-testbed/launchpad/handlers" + "openfeature.com/flagd-testbed/launchpad/pkg" ) -var ( - flagdCmd *exec.Cmd - flagdLock sync.Mutex - currentConfig = "default" // Default fallback configuration - inputDir = "./rawflags" - outputDir = "./flags" - outputFile = filepath.Join(outputDir, "allFlags.json") -) - -func stopFlagd() error { - flagdLock.Lock() - defer flagdLock.Unlock() - - if flagdCmd != nil && flagdCmd.Process != nil { - if err := flagdCmd.Process.Kill(); err != nil { - return fmt.Errorf("failed to stop flagd: %v", err) - } - flagdCmd = nil - } - return nil -} - -func startFlagdHandler(w http.ResponseWriter, r *http.Request) { - config := r.URL.Query().Get("config") - err := startFlagd(config) - if err != nil { - http.Error(w, "Failed to start flagd: "+err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf("flagd started with config: %s", config))) -} - -func startFlagd(config string) error { - if config == "" { - config = currentConfig // Use the last configuration or "default" - } else { - currentConfig = config // Update the current configuration - } - - // Stop any currently running flagd instance - if err := stopFlagd(); err != nil { - return err - } - - configPath := "./configs/" + config + ".json" - - // Start a new instance - flagdLock.Lock() - defer flagdLock.Unlock() - flagdCmd = exec.Command("./flagd", "start", "--config", configPath) - // Set up the output of flagd to be printed to the current terminal (stdout) - flagdCmd.Stdout = os.Stdout - flagdCmd.Stderr = os.Stderr - - if err := flagdCmd.Start(); err != nil { - return err - } - log.Println("started flagd with config ", currentConfig) - return nil -} - -func stopFlagdHandler(w http.ResponseWriter, r *http.Request) { - if err := stopFlagd(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - w.Write([]byte("flagd stopped")) - log.Println("stopped flagd with config ", currentConfig) -} - -type FlagConfig struct { - Flags map[string]struct { - State string `json:"state"` - Variants map[string]string `json:"variants"` - DefaultVariant string `json:"defaultVariant"` - } `json:"flags"` -} - -func restartHandler(w http.ResponseWriter, r *http.Request) { - // Parse the "seconds" query parameter - secondsStr := r.URL.Query().Get("seconds") - if secondsStr == "" { - secondsStr = "5" - } - - seconds, err := strconv.Atoi(secondsStr) - if err != nil || seconds < 0 { - http.Error(w, "'seconds' must be a non-negative integer", http.StatusBadRequest) - return - } - - fmt.Println("flagd will be stopped for restart\n") - // Stop flagd - if err := stopFlagd(); err != nil { - http.Error(w, fmt.Sprintf("Failed to stop flagd: %v", err), http.StatusInternalServerError) - return - } - - err = os.Remove(outputFile) - if err != nil { - fmt.Printf("failed to remove file - %v", err) - } - - fmt.Fprintf(w, "flagd will restart in %d seconds...\n", seconds) - - // Restart flagd after the specified delay - go func(delay int) { - time.Sleep(time.Duration(delay) * time.Second) - // Initialize the combined JSON file on startup - if err := CombineJSONFiles(); err != nil { - fmt.Printf("Error during initial JSON combination: %v\n", err) - os.Exit(1) - } - - if err := startFlagd(currentConfig); err != nil { - fmt.Printf("Failed to restart flagd: %v\n", err) - } else { - fmt.Println("flagd restarted successfully.") - } - }(seconds) -} - -var mu sync.Mutex // Mutex to ensure thread-safe file operations - -func changeHandler(w http.ResponseWriter, r *http.Request) { - mu.Lock() // Lock to ensure only one operation happens at a time - defer mu.Unlock() - - // Path to the configuration file - configFile := filepath.Join(inputDir, "changing-flag.json") - - // Read the existing file - data, err := os.ReadFile(configFile) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusInternalServerError) - return - } - - // Parse the JSON into the FlagConfig struct - var config FlagConfig - if err := json.Unmarshal(data, &config); err != nil { - http.Error(w, fmt.Sprintf("Failed to parse JSON: %v", err), http.StatusInternalServerError) - return - } - - // Find the "changing-flag" and toggle the default variant - flag, exists := config.Flags["changing-flag"] - if !exists { - http.Error(w, "Flag 'changing-flag' not found in the configuration", http.StatusNotFound) - return - } - - // Toggle the defaultVariant between "foo" and "bar" - if flag.DefaultVariant == "foo" { - flag.DefaultVariant = "bar" - } else { - flag.DefaultVariant = "foo" - } - - // Save the updated flag back to the configuration - config.Flags["changing-flag"] = flag - - // Serialize the updated configuration back to JSON - updatedData, err := json.MarshalIndent(config, "", " ") - if err != nil { - http.Error(w, fmt.Sprintf("Failed to serialize updated JSON: %v", err), http.StatusInternalServerError) - return - } - - // Write the updated JSON back to the file - if err := os.WriteFile(configFile, updatedData, 0644); err != nil { - http.Error(w, fmt.Sprintf("Failed to write updated file: %v", err), http.StatusInternalServerError) - return - } - - // Respond to the client with success - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Default variant successfully changed to '%s'\n", flag.DefaultVariant) -} - -func deepMerge(dst, src map[string]interface{}) map[string]interface{} { - for key, srcValue := range src { - if dstValue, exists := dst[key]; exists { - // If both values are maps, merge recursively - if srcMap, ok := srcValue.(map[string]interface{}); ok { - if dstMap, ok := dstValue.(map[string]interface{}); ok { - dst[key] = deepMerge(dstMap, srcMap) - continue - } - } - } - // Overwrite or add the value from src to dst - dst[key] = srcValue - } - return dst -} - -func CombineJSONFiles() error { - files, err := os.ReadDir(inputDir) - if err != nil { - return fmt.Errorf("failed to read input directory: %v", err) - } - - combinedData := make(map[string]interface{}) - - for _, file := range files { - fmt.Printf("read JSON %s\n", file.Name()) - if filepath.Ext(file.Name()) == ".json" && file.Name() != "selector-flags.json" { - filePath := filepath.Join(inputDir, file.Name()) - content, err := ioutil.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read file %s: %v", file.Name(), err) - } - - var data map[string]interface{} - if err := json.Unmarshal(content, &data); err != nil { - return fmt.Errorf("failed to parse JSON file %s: %v", file.Name(), err) - } - - // Perform deep merge - combinedData = deepMerge(combinedData, data) - } - } - - // Ensure output directory exists - if err := os.MkdirAll(outputDir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create output directory: %v", err) - } - - // Write the combined data to the output file - combinedContent, err := json.MarshalIndent(combinedData, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize combined JSON: %v", err) - } - - if err := ioutil.WriteFile(outputFile, combinedContent, 0644); err != nil { - return fmt.Errorf("failed to write combined JSON to file: %v", err) - } - - fmt.Printf("Combined JSON written to %s\n", outputFile) - return nil -} - -// startFileWatcher initializes a file watcher on the input directory to auto-update combined.json. -func startFileWatcher() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("failed to create file watcher: %v", err) - } - - go func() { - defer watcher.Close() - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - // Watch for create, write, or remove events - if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove) != 0 { - fmt.Println("Change detected in input directory. Regenerating combined.json...") - if err := CombineJSONFiles(); err != nil { - fmt.Printf("Error combining JSON files: %v\n", err) - } - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - fmt.Printf("File watcher error: %v\n", err) - } - } - }() - - // Watch the input directory - if err := watcher.Add(inputDir); err != nil { - return fmt.Errorf("failed to watch input directory: %v", err) - } - - fmt.Printf("File watcher started on %s\n", inputDir) - return nil -} - func main() { - // Create a context that listens for interrupt or terminate signals ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGINT) defer stop() - // Initialize the combined JSON file on startup - if err := CombineJSONFiles(); err != nil { - fmt.Printf("Error during initial JSON combination: %v\n", err) - os.Exit(1) + if err := flagd.CombineJSONFiles(flagd.InputDir); err != nil { + log.Fatalf("Error during initial JSON combination: %v", err) } - // Start the file watcher - if err := startFileWatcher(); err != nil { - fmt.Printf("Error starting file watcher: %v\n", err) - os.Exit(1) + if err := flagd.StartFileWatcher(); err != nil { + log.Fatalf("Error starting file watcher: %v", err) } - // Define your HTTP handlers - http.HandleFunc("/start", startFlagdHandler) - http.HandleFunc("/restart", restartHandler) - http.HandleFunc("/stop", stopFlagdHandler) - http.HandleFunc("/change", changeHandler) + http.HandleFunc("/start", handlers.StartFlagdHandler) + http.HandleFunc("/restart", handlers.RestartHandler) + http.HandleFunc("/stop", handlers.StopFlagdHandler) + http.HandleFunc("/change", handlers.ChangeHandler) - // Create the server server := &http.Server{Addr: ":8080"} - // We put the signal handler into a goroutine go func() { <-ctx.Done() - log.Printf("Received signal. Trying to shut down gracefully") + log.Println("Shutting down...") timeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - // Make sure all the resources the context holds are released - // When we exit the goroutine. defer cancel() - - // Two options here: either srv.Shutdown() manages to shutdown the server - // (semi-)gracefully within the timeout or we will be SIGKILLed by the OS. - log.Printf("shutdown") - server.Shutdown(timeout) - log.Printf("done") + err := server.Shutdown(timeout) + if err != nil { + fmt.Println("could not stop server", err) + } }() - err := startFlagd("default") + err := flagd.StartFlagd("default") if err != nil { fmt.Printf("Failed to start flagd: %v\n", err) os.Exit(1) } - fmt.Println("Server is running on port 8080...") - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - fmt.Printf("Failed to start server: %v\n", err) + + log.Println("Server running on port 8080...") + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) } - os.Remove(outputFile) - fmt.Println("Server stopped.") + if err := os.Remove(flagd.OutputFile); err != nil { + fmt.Printf("Failed to remove output file: %v\n", err) + } } diff --git a/launchpad/pkg/filewatcher.go b/launchpad/pkg/filewatcher.go new file mode 100644 index 0000000..103fdd2 --- /dev/null +++ b/launchpad/pkg/filewatcher.go @@ -0,0 +1,43 @@ +package flagd + +import ( + "fmt" + "github.com/fsnotify/fsnotify" +) + +func StartFileWatcher() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %v", err) + } + + go func() { + defer watcher.Close() + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove) != 0 { + fmt.Println("Config changed, regenerating JSON...") + if err := CombineJSONFiles(InputDir); err != nil { + fmt.Printf("Error combining JSON files: %v\n", err) + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + fmt.Printf("File watcher error: %v\n", err) + } + } + }() + + if err := watcher.Add("./rawflags"); err != nil { + return fmt.Errorf("failed to watch input directory: %v", err) + } + + fmt.Println("File watcher started.") + return nil +} diff --git a/launchpad/pkg/flagd.go b/launchpad/pkg/flagd.go new file mode 100644 index 0000000..3d04af2 --- /dev/null +++ b/launchpad/pkg/flagd.go @@ -0,0 +1,140 @@ +package flagd + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +var ( + flagdCmd *exec.Cmd + flagdLock sync.Mutex + Config = "default" + restartCancelFunc context.CancelFunc // Stores the cancel function for delayed restarts +) + +func RestartFlagd(seconds int) { + flagdLock.Lock() + if restartCancelFunc != nil { + restartCancelFunc() + fmt.Println("Previous restart canceled.") + } + + ctx, cancel := context.WithCancel(context.Background()) + restartCancelFunc = cancel + + err := stopFlagDWithoutLock() + if err != nil { + fmt.Printf("Failed to restart flagd: %v\n", err) + } + flagdLock.Unlock() + + go func() { + fmt.Printf("flagd will restart in %d seconds...\n", seconds) + select { + case <-time.After(time.Duration(seconds) * time.Second): + fmt.Println("Restarting flagd now...") + if err := StartFlagd(Config); err != nil { + fmt.Printf("Failed to restart flagd: %v\n", err) + } else { + fmt.Println("flagd restarted successfully.") + } + case <-ctx.Done(): + fmt.Println("Restart canceled before execution.") + } + }() +} + +func StartFlagd(config string) error { + if config == "" { + config = Config + } else { + Config = config + } + + flagdLock.Lock() + if err := stopFlagDWithoutLock(); err != nil { + return err + } + // Cancel any pending restart attempts + if restartCancelFunc != nil { + restartCancelFunc() + fmt.Println("Pending restart canceled due to manual start.") + } + configPath := fmt.Sprintf("./configs/%s.json", config) + + flagdCmd = exec.Command("./flagd", "start", "--config", configPath) + + stdout, err := flagdCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to capture stdout: %v", err) + } + stderr, err := flagdCmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to capture stderr: %v", err) + } + + if err := flagdCmd.Start(); err != nil { + return fmt.Errorf("failed to start flagd: %v", err) + } + + flagdLock.Unlock() + ready := make(chan bool) + + go monitorOutput(stdout, ready) + go monitorOutput(stderr, ready) + + select { + case success := <-ready: + if success { + fmt.Println("flagd started successfully.") + return nil + } + return fmt.Errorf("flagd did not start correctly") + case <-time.After(10 * time.Second): + err := StopFlagd() + if err != nil { + fmt.Println("could not stop flagd", err) + } + return fmt.Errorf("flagd start timeout exceeded") + } +} + +func StopFlagd() error { + flagdLock.Lock() + defer flagdLock.Unlock() + + err := stopFlagDWithoutLock() + if err != nil { + return err + } + return nil +} + +func stopFlagDWithoutLock() error { + if flagdCmd != nil && flagdCmd.Process != nil { + if err := flagdCmd.Process.Kill(); err != nil { + return fmt.Errorf("failed to stop flagd: %v", err) + } + flagdCmd = nil + } + return nil +} + +func monitorOutput(pipe io.ReadCloser, ready chan bool) { + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + line := scanner.Text() + fmt.Println("[flagd]:", line) + if ready != nil && strings.Contains(line, "listening at") { + ready <- true + close(ready) + return + } + } +} diff --git a/launchpad/pkg/json.go b/launchpad/pkg/json.go new file mode 100644 index 0000000..440dd07 --- /dev/null +++ b/launchpad/pkg/json.go @@ -0,0 +1,72 @@ +package flagd + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sync" +) + +var ( + mu sync.Mutex + InputDir = "./rawflags" + outputDir = "./flags" + OutputFile = filepath.Join(outputDir, "allFlags.json") +) + +func CombineJSONFiles(inputDir string) error { + mu.Lock() + defer mu.Unlock() + + files, err := os.ReadDir(inputDir) + if err != nil { + return fmt.Errorf("failed to read input directory: %v", err) + } + + combinedData := make(map[string]interface{}) + + for _, file := range files { + if filepath.Ext(file.Name()) == ".json" { + filePath := filepath.Join(inputDir, file.Name()) + content, err := ioutil.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %s: %v", file.Name(), err) + } + + var data map[string]interface{} + if err := json.Unmarshal(content, &data); err != nil { + return fmt.Errorf("failed to parse JSON file %s: %v", file.Name(), err) + } + + combinedData = deepMerge(combinedData, data) + } + } + + if err := os.MkdirAll(outputDir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create output directory: %v", err) + } + + combinedContent, err := json.MarshalIndent(combinedData, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize combined JSON: %v", err) + } + + return ioutil.WriteFile(OutputFile, combinedContent, 0644) +} + +func deepMerge(dst, src map[string]interface{}) map[string]interface{} { + for key, srcValue := range src { + if dstValue, exists := dst[key]; exists { + if srcMap, ok := srcValue.(map[string]interface{}); ok { + if dstMap, ok := dstValue.(map[string]interface{}); ok { + dst[key] = deepMerge(dstMap, srcMap) + continue + } + } + } + dst[key] = srcValue + } + return dst +} diff --git a/launchpad/pkg/json_test.go b/launchpad/pkg/json_test.go new file mode 100644 index 0000000..1a68622 --- /dev/null +++ b/launchpad/pkg/json_test.go @@ -0,0 +1,40 @@ +package flagd + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +// Test JSON merging +func TestCombineJSONFiles(t *testing.T) { + // Setup: Create temp input directory + testInputDir := "./test_rawflags" + os.MkdirAll(testInputDir, os.ModePerm) + defer os.RemoveAll(testInputDir) // Cleanup after test + + // Create sample JSON files + json1 := `{"flags": {"feature1": {"state": "on"}}}` + json2 := `{"flags": {"feature2": {"state": "off"}}}` + os.WriteFile(testInputDir+"/file1.json", []byte(json1), 0644) + os.WriteFile(testInputDir+"/file2.json", []byte(json2), 0644) + + // Run function + err := CombineJSONFiles(testInputDir) + if err != nil { + t.Fatalf("CombineJSONFiles failed: %v", err) + } + + // Verify output + expected := `{"flags":{"feature1":{"state":"on"},"feature2":{"state":"off"}}}` + data, _ := os.ReadFile(OutputFile) + + var v1, v2 interface{} + + json.Unmarshal([]byte(expected), &v1) + json.Unmarshal(data, &v2) + if !reflect.DeepEqual(v1, v2) { + t.Errorf("Expected %s, got %s", expected, string(data)) + } +}