From 0e0ff2419564fd014600dfc2dd9127c28c4f2e88 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 13 Dec 2025 14:37:29 +0800 Subject: [PATCH] :sparkles: Launchpad --- README.md | 50 +++++++++ launchpad.toml | 61 ++++++++++ pkg/config/Dockerfile | 33 ++++++ pkg/gateway/Dockerfile | 32 ++++++ pkg/launchpad/main.go | 250 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 426 insertions(+) create mode 100644 launchpad.toml create mode 100644 pkg/config/Dockerfile create mode 100644 pkg/gateway/Dockerfile create mode 100644 pkg/launchpad/main.go diff --git a/README.md b/README.md index 91b8330..bb71019 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,56 @@ A modular service framework. +## Launchpad (Process Manager) + +The `launchpad` is a CLI tool located in `pkg/launchpad` designed to simplify development and production workflows for the entire Turbine project. It acts as a process manager that can run all defined services concurrently for development and generate a `docker-compose.yml` file for production deployments. + +### Configuration + +The launchpad is configured via a `launchpad.toml` file in the project root. This file defines all the services, including how to run them in development and how to build them for production. + +**`launchpad.toml` example:** +```toml +[[services]] +name = "gateway" +type = "go" +path = "./pkg/gateway" +[services.dev] +command = "go run ./main.go" +[services.prod] +dockerfile = "./pkg/gateway/Dockerfile" +image = "turbine/gateway:latest" +ports = ["8080:8080"] + +[[services]] +name = "orders-api" +type = "dotnet" +path = "../turbine-dotnet-services/orders-api" +[services.dev] +command = "dotnet watch run" +[services.prod] +dockerfile = "../turbine-dotnet-services/orders-api/Dockerfile" +image = "turbine/orders-api:latest" +``` + +### Commands + +To use the launchpad, run its `main.go` file with one of the following commands: + +#### Development (`dev`) +Starts all services defined in `launchpad.toml` in development mode. Each service runs in a separate process, and their logs are streamed to the console with colored prefixes. A single `Ctrl+C` will gracefully shut down all services. + +```bash +go run ./pkg/launchpad/main.go dev +``` + +#### Production Docker Compose Generation (`prod-gen`) +Generates a `docker-compose.yml` file in the project root based on the `prod` configuration of all services in `launchpad.toml`. This file can be used to build and run all services as Docker containers. + +```bash +go run ./pkg/launchpad/main.go prod-gen +``` + ## Registrar The Registrar is the service discovery system of the DysonNetwork. diff --git a/launchpad.toml b/launchpad.toml new file mode 100644 index 0000000..1178b6c --- /dev/null +++ b/launchpad.toml @@ -0,0 +1,61 @@ +# launchpad.toml + +# An array of services that the launchpad can manage. +[[services]] +name = "gateway" +type = "go" +# Path to the service's source code, relative to the project root. +path = "./pkg/gateway" + +# --- Development Configuration --- +# Used by the 'launchpad dev' command. +[services.dev] +# Command to run the service from source. +command = "go run ./main.go" + +# --- Production Configuration --- +# Used by the 'launchpad prod-gen' command to generate docker-compose.yml. +[services.prod] +dockerfile = "./pkg/gateway/Dockerfile" +image = "turbine/gateway:latest" +ports = ["8080:8080"] +depends_on = ["etcd"] + + +[[services]] +name = "config" +type = "go" +path = "./pkg/config" + +[services.dev] +command = "go run ./main.go" + +[services.prod] +dockerfile = "./pkg/config/Dockerfile" +image = "turbine/config:latest" +depends_on = ["etcd"] + + +[[services]] +name = "orders-api" +type = "dotnet" +# IMPORTANT: This path should be updated to point to the actual location of the .NET project. +path = "../turbine-dotnet-services/orders-api" + +[services.dev] +# Example of running from source: +command = "dotnet watch run" +# Example of running a pre-built development image (uncomment to use): +# image = "my-dev-registry/orders-api:dev-latest" + +[services.prod] +# Path to the Dockerfile for the .NET service. +dockerfile = "../turbine-dotnet-services/orders-api/Dockerfile" +image = "turbine/orders-api:latest" +depends_on = ["etcd", "config"] +# Environment variables to be set in docker-compose. +environment = [ + "ASPNETCORE_ENVIRONMENT=Production", + # The URL for the config service, accessible via the docker network. + "CONFIG_URL=http://config" +] diff --git a/pkg/config/Dockerfile b/pkg/config/Dockerfile new file mode 100644 index 0000000..a4cfec3 --- /dev/null +++ b/pkg/config/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1: Build the Go binary +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Copy go.mod and go.sum to download dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the rest of the source code +COPY . . + +# Build the config service +WORKDIR /app/pkg/config +RUN CGO_ENABLED=0 GOOS=linux go build -v -o /app/server . + +# Stage 2: Create the final minimal image +FROM alpine:latest + +WORKDIR /app + +# Copy the binary from the builder stage +COPY --from=builder /app/server . + +# Copy the settings and shared config files +COPY ./pkg/config/settings.toml . +COPY ./pkg/config/shared_config.toml . + +# Expose the port the service listens on +EXPOSE 8081 + +# Command to run the service +CMD ["/app/server"] diff --git a/pkg/gateway/Dockerfile b/pkg/gateway/Dockerfile new file mode 100644 index 0000000..99a46d4 --- /dev/null +++ b/pkg/gateway/Dockerfile @@ -0,0 +1,32 @@ +# Stage 1: Build the Go binary +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Copy go.mod and go.sum to download dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the rest of the source code +COPY . . + +# Build the gateway service +WORKDIR /app/pkg/gateway +RUN CGO_ENABLED=0 GOOS=linux go build -v -o /app/server . + +# Stage 2: Create the final minimal image +FROM alpine:latest + +WORKDIR /app + +# Copy the binary from the builder stage +COPY --from=builder /app/server . + +# Copy the settings file +COPY ./pkg/gateway/settings.toml . + +# Expose the port the gateway listens on +EXPOSE 8080 + +# Command to run the service +CMD ["/app/server"] diff --git a/pkg/launchpad/main.go b/pkg/launchpad/main.go new file mode 100644 index 0000000..ac8030e --- /dev/null +++ b/pkg/launchpad/main.go @@ -0,0 +1,250 @@ +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) +}