✨ Better launchpad
This commit is contained in:
76
pkg/launchpad/config/config.go
Normal file
76
pkg/launchpad/config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// LaunchpadConfig is the top-level configuration structure for the launchpad.
|
||||
type LaunchpadConfig struct {
|
||||
Variables struct {
|
||||
Required []string
|
||||
}
|
||||
Networks map[string]interface{}
|
||||
Services []Service
|
||||
}
|
||||
|
||||
// Service defines a single service that can be managed by the launchpad.
|
||||
type Service struct {
|
||||
Name string
|
||||
Type string
|
||||
Path string
|
||||
Dev struct {
|
||||
Command string
|
||||
Image string
|
||||
ExposePorts []int `mapstructure:"expose_ports"` // Ports to check for health in dev mode
|
||||
Healthcheck Healthcheck
|
||||
}
|
||||
Prod struct {
|
||||
Image string
|
||||
Command interface{}
|
||||
Dockerfile string
|
||||
BuildContext string `mapstructure:"build_context"`
|
||||
Ports []string
|
||||
Expose []string
|
||||
Environment []string
|
||||
Volumes []string
|
||||
DependsOn map[string]Dependency `mapstructure:"depends_on"`
|
||||
Networks []string
|
||||
Healthcheck Healthcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Healthcheck defines a health check for a service.
|
||||
type Healthcheck struct {
|
||||
Test []string `yaml:"test,omitempty"`
|
||||
Interval string `yaml:"interval,omitempty"`
|
||||
Timeout string `yaml:"timeout,omitempty"`
|
||||
Retries int `yaml:"retries,omitempty"`
|
||||
// For dev mode TCP checks
|
||||
TcpPorts []int `mapstructure:"tcp_ports"`
|
||||
Path string
|
||||
}
|
||||
|
||||
// Dependency defines a dependency condition for docker-compose.
|
||||
type Dependency struct {
|
||||
Condition string `yaml:"condition"`
|
||||
}
|
||||
|
||||
|
||||
// Load reads and parses the launchpad.toml file from the project root.
|
||||
func Load() 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
|
||||
}
|
||||
102
pkg/launchpad/deploy/deploy.go
Normal file
102
pkg/launchpad/deploy/deploy.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.solsynth.dev/goatworks/turbine/pkg/launchpad/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// --- Docker Compose Structures for YAML Marshalling ---
|
||||
|
||||
type ComposeFile struct {
|
||||
Version string `yaml:"version"`
|
||||
Services map[string]ComposeService `yaml:"services"`
|
||||
Networks map[string]interface{} `yaml:"networks,omitempty"`
|
||||
}
|
||||
|
||||
type ComposeService struct {
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Build *ComposeBuild `yaml:"build,omitempty"`
|
||||
Command interface{} `yaml:"command,omitempty"`
|
||||
Ports []string `yaml:"ports,omitempty"`
|
||||
Expose []string `yaml:"expose,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Volumes []string `yaml:"volumes,omitempty"`
|
||||
DependsOn map[string]config.Dependency `yaml:"depends_on,omitempty"`
|
||||
Networks []string `yaml:"networks,omitempty"`
|
||||
Healthcheck *config.Healthcheck `yaml:"healthcheck,omitempty"`
|
||||
}
|
||||
|
||||
type ComposeBuild struct {
|
||||
Context string `yaml:"context,omitempty"`
|
||||
Dockerfile string `yaml:"dockerfile,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateDockerCompose creates a docker-compose.yml file from the launchpad config.
|
||||
func GenerateDockerCompose(cfg config.LaunchpadConfig) {
|
||||
compose := ComposeFile{
|
||||
Version: "3.8",
|
||||
Services: make(map[string]ComposeService),
|
||||
Networks: cfg.Networks,
|
||||
}
|
||||
|
||||
for _, s := range cfg.Services {
|
||||
subst := func(val string) string {
|
||||
return os.ExpandEnv(val)
|
||||
}
|
||||
|
||||
composeService := ComposeService{
|
||||
Image: subst(s.Prod.Image),
|
||||
Command: s.Prod.Command,
|
||||
Ports: s.Prod.Ports,
|
||||
Expose: s.Prod.Expose,
|
||||
Volumes: s.Prod.Volumes,
|
||||
DependsOn: s.Prod.DependsOn,
|
||||
Networks: s.Prod.Networks,
|
||||
}
|
||||
|
||||
// Add healthcheck if defined
|
||||
if len(s.Prod.Healthcheck.Test) > 0 {
|
||||
composeService.Healthcheck = &s.Prod.Healthcheck
|
||||
}
|
||||
|
||||
if s.Prod.Dockerfile != "" {
|
||||
context := "."
|
||||
if s.Prod.BuildContext != "" {
|
||||
context = s.Prod.BuildContext
|
||||
}
|
||||
composeService.Build = &ComposeBuild{
|
||||
Context: context,
|
||||
Dockerfile: s.Prod.Dockerfile,
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.Prod.Environment) > 0 {
|
||||
envMap := make(map[string]string)
|
||||
for _, env := range s.Prod.Environment {
|
||||
parts := strings.SplitN(subst(env), "=", 2)
|
||||
if len(parts) == 2 {
|
||||
envMap[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
composeService.Environment = envMap
|
||||
}
|
||||
|
||||
compose.Services[s.Name] = composeService
|
||||
}
|
||||
|
||||
yamlData, err := yaml.Marshal(&compose)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to generate YAML for docker-compose")
|
||||
}
|
||||
|
||||
outFile := "docker-compose.yml"
|
||||
if err := os.WriteFile(outFile, yamlData, 0o644); err != nil {
|
||||
log.Fatal().Err(err).Msgf("Failed to write %s", outFile)
|
||||
}
|
||||
|
||||
log.Info().Msgf("Successfully generated %s", outFile)
|
||||
}
|
||||
263
pkg/launchpad/dev/dev.go
Normal file
263
pkg/launchpad/dev/dev.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package dev
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.solsynth.dev/goatworks/turbine/pkg/launchpad/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var colors = []string{
|
||||
"\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m", "\033[31m",
|
||||
}
|
||||
|
||||
const colorReset = "\033[0m"
|
||||
|
||||
// RunDev starts all services defined in the config in development mode.
|
||||
func RunDev(cfg config.LaunchpadConfig) {
|
||||
log.Info().Msg("Starting services in development mode with dependency checks...")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var wg sync.WaitGroup
|
||||
|
||||
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()
|
||||
}()
|
||||
|
||||
devNetwork := "turbine-dev-net"
|
||||
if len(cfg.Networks) > 0 {
|
||||
for name := range cfg.Networks {
|
||||
devNetwork = name
|
||||
break
|
||||
}
|
||||
}
|
||||
createDockerNetwork(devNetwork)
|
||||
|
||||
// --- Dependency-aware startup ---
|
||||
serviceMap := make(map[string]config.Service)
|
||||
for _, s := range cfg.Services {
|
||||
serviceMap[s.Name] = s
|
||||
}
|
||||
|
||||
started := make(map[string]chan bool)
|
||||
for _, s := range cfg.Services {
|
||||
if _, exists := started[s.Name]; !exists {
|
||||
startServiceWithDeps(ctx, &wg, s, serviceMap, started, devNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
log.Info().Msg("All services have been shut down.")
|
||||
}
|
||||
|
||||
func startServiceWithDeps(ctx context.Context, wg *sync.WaitGroup, s config.Service, serviceMap map[string]config.Service, started map[string]chan bool, network string) {
|
||||
if _, exists := started[s.Name]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a channel that will be closed when this service is healthy
|
||||
healthyChan := make(chan bool)
|
||||
started[s.Name] = healthyChan
|
||||
|
||||
// First, recursively start dependencies
|
||||
var depNames []string
|
||||
for depName := range s.Prod.DependsOn {
|
||||
depNames = append(depNames, depName)
|
||||
}
|
||||
|
||||
for _, depName := range depNames {
|
||||
if dep, ok := serviceMap[depName]; ok {
|
||||
startServiceWithDeps(ctx, wg, dep, serviceMap, started, network)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for dependencies to be healthy
|
||||
log.Info().Str("service", s.Name).Msgf("Waiting for dependencies to be healthy: %v", depNames)
|
||||
for _, depName := range depNames {
|
||||
if depChan, ok := started[depName]; ok {
|
||||
log.Info().Str("service", s.Name).Msgf("Waiting for %s...", depName)
|
||||
select {
|
||||
case <-depChan:
|
||||
log.Info().Str("service", s.Name).Msgf("Dependency %s is healthy.", depName)
|
||||
case <-ctx.Done():
|
||||
log.Warn().Str("service", s.Name).Msg("Shutdown signal received, aborting startup.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now, start the actual service
|
||||
wg.Add(1)
|
||||
go func(s config.Service, color string) {
|
||||
defer wg.Done()
|
||||
|
||||
var healthCheckPorts []int
|
||||
if s.Type == "docker" {
|
||||
// For docker, we use the dev healthcheck ports to also map them
|
||||
healthCheckPorts = s.Dev.Healthcheck.TcpPorts
|
||||
startDockerService(ctx, s, color, network, healthCheckPorts)
|
||||
} else if s.Dev.Command != "" {
|
||||
healthCheckPorts = s.Dev.Healthcheck.TcpPorts
|
||||
startSourceService(ctx, s, color)
|
||||
} else {
|
||||
log.Warn().Str("service", s.Name).Msg("No dev.command or docker type, skipping.")
|
||||
close(healthyChan) // Mark as "healthy" so other things can proceed
|
||||
return
|
||||
}
|
||||
|
||||
// Perform health check on the service we just started
|
||||
waitForHealth(ctx, s.Name, healthCheckPorts)
|
||||
close(healthyChan) // Signal that this service is now healthy
|
||||
|
||||
// Block until context is cancelled to keep the service running
|
||||
// and ensure wg.Done is called at the right time.
|
||||
<-ctx.Done()
|
||||
}(s, colors[len(started)%len(colors)])
|
||||
}
|
||||
|
||||
func waitForHealth(ctx context.Context, serviceName string, ports []int) {
|
||||
if len(ports) == 0 {
|
||||
log.Info().Str("service", serviceName).Msg("No healthcheck ports defined, assuming healthy immediately.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
address := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
log.Info().Str("service", serviceName).Msgf("Waiting for %s to be available...", address)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn().Str("service", serviceName).Msg("Shutdown signal received, aborting health check.")
|
||||
return
|
||||
default:
|
||||
conn, err := net.DialTimeout("tcp", address, 1*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
log.Info().Str("service", serviceName).Msgf("%s is healthy!", address)
|
||||
goto nextPort
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
nextPort:
|
||||
}
|
||||
}
|
||||
|
||||
// startSourceService runs a service from its source code.
|
||||
func startSourceService(ctx context.Context, s config.Service, color string) {
|
||||
prefix := fmt.Sprintf("%s[%-10s]%s ", color, s.Name, colorReset)
|
||||
log.Info().Str("service", s.Name).Str("command", s.Dev.Command).Msg("Starting from source")
|
||||
|
||||
parts := strings.Fields(s.Dev.Command)
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
cmd.Dir = s.Path
|
||||
|
||||
runAndMonitorCommand(ctx, cmd, s.Name, prefix)
|
||||
}
|
||||
|
||||
// startDockerService runs a pre-built Docker image.
|
||||
func startDockerService(ctx context.Context, s config.Service, color string, network string, portsToMap []int) {
|
||||
prefix := fmt.Sprintf("%s[%-10s]%s ", color, s.Name, colorReset)
|
||||
log.Info().Str("service", s.Name).Str("image", s.Prod.Image).Msg("Starting from Docker image")
|
||||
|
||||
containerName := fmt.Sprintf("%s-dev", s.Name)
|
||||
exec.Command("docker", "rm", "-f", containerName).Run()
|
||||
|
||||
args := []string{"run", "--rm", "-i", "--name", containerName}
|
||||
if network != "" {
|
||||
args = append(args, "--network", network)
|
||||
}
|
||||
for _, p := range portsToMap {
|
||||
args = append(args, "-p", fmt.Sprintf("%d:%d", p, p))
|
||||
}
|
||||
for _, e := range s.Prod.Environment {
|
||||
args = append(args, "-e", os.ExpandEnv(e))
|
||||
}
|
||||
|
||||
args = append(args, os.ExpandEnv(s.Prod.Image))
|
||||
|
||||
if s.Prod.Command != nil {
|
||||
switch v := s.Prod.Command.(type) {
|
||||
case string:
|
||||
args = append(args, strings.Fields(v)...)
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
args = append(args, fmt.Sprintf("%v", item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info().Str("service", s.Name).Msg("Stopping docker container...")
|
||||
stopCmd := exec.Command("docker", "stop", containerName)
|
||||
if err := stopCmd.Run(); err != nil {
|
||||
log.Warn().Err(err).Str("service", s.Name).Msg("Failed to stop container.")
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := exec.Command("docker", args...)
|
||||
runAndMonitorCommand(ctx, cmd, s.Name, prefix)
|
||||
}
|
||||
|
||||
func runAndMonitorCommand(ctx context.Context, cmd *exec.Cmd, serviceName, prefix string) {
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Error().Err(err).Str("service", serviceName).Msg("Failed to start")
|
||||
return
|
||||
}
|
||||
|
||||
go streamOutput(stdout, prefix)
|
||||
go streamOutput(stderr, prefix)
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
log.Info().Str("service", serviceName).Msg("Process stopped.")
|
||||
} else if err != nil {
|
||||
log.Error().Err(err).Str("service", serviceName).Msg("Exited with error")
|
||||
} else {
|
||||
log.Info().Str("service", serviceName).Msg("Exited")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func streamOutput(pipe io.ReadCloser, prefix string) {
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
for scanner.Scan() {
|
||||
fmt.Printf("%s%s\n", prefix, scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func createDockerNetwork(networkName string) {
|
||||
log.Info().Str("network", networkName).Msg("Ensuring docker network exists")
|
||||
cmd := exec.Command("docker", "network", "inspect", networkName)
|
||||
if err := cmd.Run(); err == nil {
|
||||
log.Info().Str("network", networkName).Msg("Network already exists.")
|
||||
return
|
||||
}
|
||||
|
||||
cmd = exec.Command("docker", "network", "create", networkName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Warn().Err(err).Msg("Could not create docker network.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,250 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/template"
|
||||
|
||||
"git.solsynth.dev/goatworks/turbine/pkg/launchpad/config"
|
||||
"git.solsynth.dev/goatworks/turbine/pkg/launchpad/deploy"
|
||||
"git.solsynth.dev/goatworks/turbine/pkg/launchpad/dev"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"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() {
|
||||
// Initialize logging
|
||||
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")
|
||||
// Load .env file if it exists
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Info().Msg("No .env file found, relying on environment variables.")
|
||||
}
|
||||
|
||||
config := readConfig()
|
||||
// Check for command-line arguments
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal().Msg("Usage: launchpad <command>\nCommands: dev, deploy")
|
||||
}
|
||||
|
||||
// Load the main launchpad configuration
|
||||
cfg := config.Load()
|
||||
command := os.Args[1]
|
||||
|
||||
// Dispatch to the correct handler
|
||||
switch command {
|
||||
case "dev":
|
||||
runDev(config)
|
||||
dev.RunDev(cfg)
|
||||
case "deploy":
|
||||
log.Info().Msg("Generating docker-compose.yml for production...")
|
||||
generateDockerCompose(config)
|
||||
log.Info().Msg("Generating docker-compose.yml for production deployment...")
|
||||
deploy.GenerateDockerCompose(cfg)
|
||||
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