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