mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 03:11:46 +00:00
9a6bf692ca
* feat(ecs): provision EC2 container-instance capacity via IMDS instance-role Adds a ProvisionCapacity gateway action that launches spinifex-ecs-node EC2 instances which register as ECS container instances, fixing RESOURCE:placement when a cluster has no capacity. Agent control-plane now SigV4-signs gateway calls with instance-role credentials read from IMDS (no static keys seeded in user-data). The gateway already verifies STS/temp creds, so the instance role carries an ecs:* policy + ec2 trust; ensureECSInstanceProfile find-or-creates the ecsInstanceRole instance profile attached at launch. Backend resolves the spinifex-ecs-node AMI by spinifex:managed-by=ecs tag, renders keyless user-data, and launches via the customer RunInstances path. Frontend adds a hand-signed JSON-1.1 ProvisionCapacity call, a provision-capacity dialog on the container-instances tab, and the ECS console section nav + system-image gating. * feat: auto-assign EIPs to awsvpc service tasks; add ECS task/service detail pages Wire AssignPublicIp=ENABLED through the service reconciler so awsvpc service tasks automatically allocate and associate an Elastic IP on the RUNNING transition, and release it on STOP. assignTaskPublicIP/releaseTaskPublicIP mirror the target-group registration hooks in recordTaskState and forceStopTask; the public IP and allocation ID are persisted on the task record and surfaced in DescribeTasks attachments as publicIPv4Address. Add AWS-parity ECS task and service detail pages showing private and public endpoints, container state, and networking configuration, reachable by clicking a task or service in the cluster tabs. * test: cover natsEIPManager allocate/associate/release over NATS
120 lines
3.2 KiB
Go
120 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultEnvFile = "/etc/spinifex-ecs/agent.env"
|
|
defaultGatewayCA = "/etc/spinifex-ecs/gateway-ca.pem"
|
|
defaultIMDSBase = "http://169.254.169.254/latest"
|
|
defaultContainerdSocket = "/run/containerd/containerd.sock"
|
|
defaultHeartbeat = 30 * time.Second
|
|
defaultPollInterval = 5 * time.Second
|
|
)
|
|
|
|
// config holds the static settings the agent reads at boot. Cluster identity
|
|
// (account ID, instance ID) is discovered at runtime from IMDS, not configured;
|
|
// only the cluster *name* the instance was launched into is static. AccessKey /
|
|
// SecretKey are legacy seeded creds, optional now that the agent SigV4-signs
|
|
// gateway calls with instance-role credentials from IMDS; the agent never holds
|
|
// a NATS token.
|
|
type config struct {
|
|
GatewayURL string
|
|
GatewayCA string
|
|
Region string
|
|
ClusterName string
|
|
AccessKey string
|
|
SecretKey string
|
|
IMDSBase string
|
|
ContainerdSocket string
|
|
Heartbeat time.Duration
|
|
PollInterval time.Duration
|
|
CredEndpointIP string
|
|
CredEndpointPort int
|
|
}
|
|
|
|
// loadConfig reads the cloud-init env file then lets real env vars override.
|
|
func loadConfig(envFile string) config {
|
|
env := parseEnvFile(envFile)
|
|
get := func(key string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return env[key]
|
|
}
|
|
|
|
cfg := config{
|
|
GatewayURL: get("ECS_GATEWAY_URL"),
|
|
GatewayCA: get("ECS_GATEWAY_CA"),
|
|
Region: get("ECS_REGION"),
|
|
ClusterName: get("ECS_CLUSTER"),
|
|
AccessKey: get("ECS_ACCESS_KEY"),
|
|
SecretKey: get("ECS_SECRET_KEY"),
|
|
IMDSBase: get("ECS_IMDS_BASE"),
|
|
ContainerdSocket: get("ECS_CONTAINERD_SOCKET"),
|
|
}
|
|
if cfg.GatewayCA == "" {
|
|
cfg.GatewayCA = defaultGatewayCA
|
|
}
|
|
if cfg.IMDSBase == "" {
|
|
cfg.IMDSBase = defaultIMDSBase
|
|
}
|
|
if cfg.ContainerdSocket == "" {
|
|
cfg.ContainerdSocket = defaultContainerdSocket
|
|
}
|
|
if cfg.ClusterName == "" {
|
|
cfg.ClusterName = "default"
|
|
}
|
|
cfg.Heartbeat = defaultHeartbeat
|
|
if v := get("ECS_HEARTBEAT_INTERVAL"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
|
cfg.Heartbeat = d
|
|
}
|
|
}
|
|
cfg.PollInterval = defaultPollInterval
|
|
if v := get("ECS_POLL_INTERVAL"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
|
cfg.PollInterval = d
|
|
}
|
|
}
|
|
cfg.CredEndpointIP = get("ECS_CRED_ENDPOINT_IP")
|
|
if cfg.CredEndpointIP == "" {
|
|
cfg.CredEndpointIP = defaultCredEndpointIP
|
|
}
|
|
cfg.CredEndpointPort = defaultCredEndpointPort
|
|
if v := get("ECS_CRED_ENDPOINT_PORT"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil && p > 0 {
|
|
cfg.CredEndpointPort = p
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
|
|
func parseEnvFile(path string) map[string]string {
|
|
out := map[string]string{}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer f.Close()
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
|
|
}
|
|
return out
|
|
}
|