✨ Launchpad
This commit is contained in:
50
README.md
50
README.md
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
A modular service framework.
|
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
|
## Registrar
|
||||||
|
|
||||||
The Registrar is the service discovery system of the DysonNetwork.
|
The Registrar is the service discovery system of the DysonNetwork.
|
||||||
|
|||||||
61
launchpad.toml
Normal file
61
launchpad.toml
Normal file
@@ -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"
|
||||||
|
]
|
||||||
33
pkg/config/Dockerfile
Normal file
33
pkg/config/Dockerfile
Normal file
@@ -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"]
|
||||||
32
pkg/gateway/Dockerfile
Normal file
32
pkg/gateway/Dockerfile
Normal file
@@ -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"]
|
||||||
250
pkg/launchpad/main.go
Normal file
250
pkg/launchpad/main.go
Normal file
@@ -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 <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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user