readysite / cmd / launch / infra.go
6.6 KB
infra.go
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// InfraConfig defines the complete infrastructure for a project.
type InfraConfig struct {
	Platform  PlatformConfig         `json:"platform"`
	Servers   map[string]ServerSpec  `json:"servers"`
	Services  map[string]ServiceSpec `json:"services,omitempty"`
	Binaries  map[string]ServiceSpec `json:"binaries,omitempty"`
	Instances map[string][]Instance  `json:"instances,omitempty"`
}

// PlatformConfig defines the cloud platform settings.
type PlatformConfig struct {
	Provider string `json:"provider"`          // e.g., "digitalocean"
	Token    string `json:"token,omitempty"`   // API token (supports $ENV_VAR)
	Project  string `json:"project,omitempty"` // Project name for naming resources
	Region   string `json:"region,omitempty"`  // Default region

	// Raw values (unexpanded) for saving back to file
	rawToken   string `json:"-"`
	rawProject string `json:"-"`
}

// ServerSpec defines a server type (e.g., "main", "data", "app")
type ServerSpec struct {
	Size      string       `json:"size"`
	Volume    *VolumeSpec  `json:"volume,omitempty"`
	Volumes   []VolumeSpec `json:"volumes,omitempty"`
	DependsOn string       `json:"depends_on,omitempty"`
	Services  []string     `json:"services,omitempty"`
	Binaries  []string     `json:"binaries,omitempty"`
}

// getVolumes returns all volumes for a server.
func (s *ServerSpec) getVolumes() []VolumeSpec {
	var vols []VolumeSpec
	if s.Volume != nil {
		vols = append(vols, *s.Volume)
	}
	vols = append(vols, s.Volumes...)
	return vols
}

// VolumeSpec defines a block storage volume to attach
type VolumeSpec struct {
	Name       string `json:"name"`
	Size       int    `json:"size"` // GB
	Mount      string `json:"mount"`
	Filesystem string `json:"filesystem,omitempty"` // "ext4" (default) or "xfs"
}

// ServiceSpec defines a Docker service
type ServiceSpec struct {
	Image      string            `json:"image"`
	Source     string            `json:"source,omitempty"` // Build from (e.g., "./example")
	Dest       string            `json:"dest,omitempty"`   // Binary path in container
	Command    []string          `json:"command,omitempty"`
	Ports      []PortSpec        `json:"ports,omitempty"`
	Volumes    []VolumeMount     `json:"volumes,omitempty"`
	Env        map[string]string `json:"env,omitempty"`
	EnvFiles   map[string]string `json:"env_files,omitempty"`
	Network     string            `json:"network,omitempty"`
	Privileged  bool              `json:"privileged,omitempty"`
	Healthcheck string            `json:"healthcheck,omitempty"` // Custom health check command (overrides default curl)
}

// PortSpec defines a port mapping
type PortSpec struct {
	Host      int    `json:"host"`
	Container int    `json:"container"`
	Bind      string `json:"bind,omitempty"`
}

// VolumeMount defines a volume mount
type VolumeMount struct {
	Source string `json:"source"`
	Target string `json:"target"`
}

// Instance represents a deployed server instance
type Instance struct {
	ID     string `json:"id,omitempty"`
	Name   string `json:"name"`
	IP     string `json:"ip,omitempty"`
	Region string `json:"region,omitempty"`
}

// loadInfraConfig loads infra.json from the project directory.
func loadInfraConfig(projectDir string) (*InfraConfig, error) {
	path := filepath.Join(projectDir, "infra.json")
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read infra.json: %w", err)
	}

	var cfg InfraConfig
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parse infra.json: %w", err)
	}

	// Initialize maps if nil
	if cfg.Instances == nil {
		cfg.Instances = make(map[string][]Instance)
	}
	if cfg.Services == nil {
		cfg.Services = make(map[string]ServiceSpec)
	}
	if cfg.Binaries == nil {
		cfg.Binaries = make(map[string]ServiceSpec)
	}

	// Store raw values before expanding
	cfg.Platform.rawToken = cfg.Platform.Token
	cfg.Platform.rawProject = cfg.Platform.Project

	// Resolve environment variables in platform config
	cfg.Platform.Token = expandEnv(cfg.Platform.Token)
	cfg.Platform.Project = expandEnv(cfg.Platform.Project)

	return &cfg, nil
}

// expandEnv expands $VAR or ${VAR} in a string from environment.
func expandEnv(s string) string {
	if strings.HasPrefix(s, "$") {
		return os.ExpandEnv(s)
	}
	return s
}

// save writes the config back to infra.json.
func (cfg *InfraConfig) save(projectDir string) error {
	path := filepath.Join(projectDir, "infra.json")

	// Restore raw env var references before saving
	savedToken := cfg.Platform.Token
	savedProject := cfg.Platform.Project
	if cfg.Platform.rawToken != "" {
		cfg.Platform.Token = cfg.Platform.rawToken
	}
	if cfg.Platform.rawProject != "" {
		cfg.Platform.Project = cfg.Platform.rawProject
	}

	data, err := json.MarshalIndent(cfg, "", "  ")

	// Restore expanded values
	cfg.Platform.Token = savedToken
	cfg.Platform.Project = savedProject

	if err != nil {
		return fmt.Errorf("marshal infra.json: %w", err)
	}

	if err := os.WriteFile(path, data, 0644); err != nil {
		return fmt.Errorf("write infra.json: %w", err)
	}

	return nil
}

// topologicalSort returns server types in dependency order.
// Detects cycles and skips cyclic dependencies with a log warning.
func topologicalSort(servers map[string]ServerSpec) []string {
	visited := make(map[string]bool)
	inStack := make(map[string]bool) // tracks current recursion path for cycle detection
	result := make([]string, 0, len(servers))

	var visit func(name string)
	visit = func(name string) {
		if visited[name] {
			return
		}
		if inStack[name] {
			fmt.Fprintf(os.Stderr, "Warning: circular dependency detected involving %q, skipping\n", name)
			return
		}
		inStack[name] = true

		spec := servers[name]
		if spec.DependsOn != "" {
			visit(spec.DependsOn)
		}

		inStack[name] = false
		visited[name] = true
		result = append(result, name)
	}

	for name := range servers {
		visit(name)
	}

	return result
}

// allInstances returns all instances across all server types.
func (cfg *InfraConfig) allInstances() []Instance {
	var all []Instance
	for _, instances := range cfg.Instances {
		all = append(all, instances...)
	}
	return all
}

// getInstanceByName finds an instance by name across all server types.
func (cfg *InfraConfig) getInstanceByName(name string) *Instance {
	for _, instances := range cfg.Instances {
		for i := range instances {
			if instances[i].Name == name {
				return &instances[i]
			}
		}
	}
	return nil
}

// nextInstanceName generates the next instance name for a server type.
func (cfg *InfraConfig) nextInstanceName(project, serverType string) string {
	instances := cfg.Instances[serverType]
	return fmt.Sprintf("%s-%s-%d", project, serverType, len(instances)+1)
}
← Back