package main import ( "bufio" "bytes" "context" "fmt" "os" "os/exec" "os/signal" "path/filepath" "strings" "sync" "syscall" "text/template" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/viper" ) const dockerComposeTemplate = `version: '3.8' services: etcd: image: bitnami/etcd:3.5 environment: - ALLOW_NONE_AUTHENTICATION=yes - ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379 ports: - "2379:2379" {{- range .Services }} {{ .Name }}: image: {{ .Prod.Image }} build: context: {{ .BuildContext }} dockerfile: {{ .Prod.Dockerfile }} {{- if .Prod.Ports }} ports: {{- range .Prod.Ports }} - "{{ . }}" {{- end }} {{- end }} {{- if .Prod.Environment }} environment: {{- range .Prod.Environment }} - {{ . }} {{- end }} {{- end }} {{- if .Prod.DependsOn }} depends_on: {{- range .Prod.DependsOn }} - {{ . }} {{- end }} {{- end }} {{- end }} ` // ANSI colors for logging var colors = []string{ "\033[32m", // Green "\033[33m", // Yellow "\033[34m", // Blue "\033[35m", // Magenta "\033[36m", // Cyan "\033[31m", // Red } const colorReset = "\033[0m" type Service struct { Name string Type string Path string BuildContext string `mapstructure:"-"` // This will be calculated, not read from file Dev struct { Command string Image string } Prod struct { Dockerfile string Image string Ports []string Environment []string DependsOn []string `mapstructure:"depends_on"` } } type LaunchpadConfig struct { Services []Service } func init() { log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) } func main() { if len(os.Args) < 2 { log.Fatal().Msg("Usage: launchpad \nCommands: dev, prod-gen") } config := readConfig() command := os.Args[1] switch command { case "dev": runDev(config) case "deploy": log.Info().Msg("Generating docker-compose.yml for production...") generateDockerCompose(config) default: log.Fatal().Msgf("Unknown command: %s", command) } } func readConfig() LaunchpadConfig { v := viper.New() v.SetConfigName("launchpad") v.AddConfigPath(".") v.SetConfigType("toml") if err := v.ReadInConfig(); err != nil { log.Fatal().Err(err).Msg("Failed to read launchpad.toml") } var config LaunchpadConfig if err := v.Unmarshal(&config); err != nil { log.Fatal().Err(err).Msg("Failed to parse launchpad.toml") } return config } func runDev(config LaunchpadConfig) { log.Info().Msg("Starting services in development mode...") ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup // --- Graceful Shutdown Handler --- sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigChan log.Info().Msgf("Received signal: %v. Shutting down all services...", sig) cancel() // Cancel the context to signal all goroutines to stop }() for i, service := range config.Services { wg.Add(1) go func(s Service, color string) { defer wg.Done() startService(ctx, s, color) }(service, colors[i%len(colors)]) } wg.Wait() log.Info().Msg("All services have been shut down.") } func startService(ctx context.Context, s Service, color string) { prefix := fmt.Sprintf("%s[%-10s]%s ", color, s.Name, colorReset) // TODO: Handle dev.image with 'docker run' if s.Dev.Command == "" { log.Warn().Str("service", s.Name).Msg("No dev.command found, skipping.") return } parts := strings.Fields(s.Dev.Command) cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) cmd.Dir = s.Path // Capture stdout and stderr stdout, _ := cmd.StdoutPipe() stderr, _ := cmd.StderrPipe() // Start the command if err := cmd.Start(); err != nil { log.Error().Err(err).Str("service", s.Name).Msg("Failed to start service") return } log.Info().Str("service", s.Name).Msg("Started") // Goroutine to stream stdout with prefix go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { fmt.Printf("%s%s\n", prefix, scanner.Text()) } }() // Goroutine to stream stderr with prefix go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { fmt.Printf("%s%s\n", prefix, scanner.Text()) } }() // Wait for the command to exit err := cmd.Wait() // Check if the context was cancelled (graceful shutdown) if ctx.Err() != nil { log.Info().Str("service", s.Name).Msg("Stopped gracefully.") } else if err != nil { // The process exited with an error log.Error().Err(err).Str("service", s.Name).Msg("Exited with error") } else { // The process exited successfully log.Info().Str("service", s.Name).Msg("Exited") } } func generateDockerCompose(config LaunchpadConfig) { // Calculate BuildContext for each service for i := range config.Services { dockerfileAbs, err := filepath.Abs(config.Services[i].Prod.Dockerfile) if err != nil { log.Fatal().Err(err).Msgf("Could not get absolute path for Dockerfile: %s", config.Services[i].Prod.Dockerfile) } projectRoot, _ := os.Getwd() if strings.HasPrefix(dockerfileAbs, projectRoot) { // It's inside, use the project root as context for simpler builds config.Services[i].BuildContext = "." } else { // It's outside, use the Dockerfile's directory as context config.Services[i].BuildContext = filepath.Dir(config.Services[i].Prod.Dockerfile) } } tmpl, err := template.New("docker-compose").Parse(dockerComposeTemplate) if err != nil { log.Fatal().Err(err).Msg("Failed to parse docker-compose template") } var buf bytes.Buffer if err := tmpl.Execute(&buf, config); err != nil { log.Fatal().Err(err).Msg("Failed to execute docker-compose template") } outFile := "docker-compose.yml" if err := os.WriteFile(outFile, buf.Bytes(), 0o644); err != nil { log.Fatal().Err(err).Msgf("Failed to write %s", outFile) } log.Info().Msgf("Successfully generated %s", outFile) }