Files
Turbine/pkg/launchpad/main.go
2025-12-13 14:37:29 +08:00

251 lines
5.9 KiB
Go

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 <command>\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)
}