package swarm import ( "bytes" "fmt" "log" "os" "os/exec" "path/filepath" "strconv" "strings" "text/template" "git.nixc.us/a250/ss-atlas/internal/config" ) type Client struct { cfg *config.Config } func New(cfg *config.Config) *Client { return &Client{cfg: cfg} } func (c *Client) DeployStack(stackName, username, domain string) error { tmplPath := filepath.Join(c.cfg.TemplatePath, "stack-template.yml") tmplBytes, err := os.ReadFile(tmplPath) if err != nil { return fmt.Errorf("read stack template: %w", err) } t, err := template.New("stack").Parse(string(tmplBytes)) if err != nil { return fmt.Errorf("parse stack template: %w", err) } data := map[string]any{ "ID": username, "Subdomain": username, "Domain": domain, "TraefikNetwork": c.cfg.TraefikNetwork, } var rendered bytes.Buffer if err := t.Execute(&rendered, data); err != nil { return fmt.Errorf("render stack template: %w", err) } tmpFile := filepath.Join(os.TempDir(), stackName+".yml") if err := os.WriteFile(tmpFile, rendered.Bytes(), 0600); err != nil { return fmt.Errorf("write temp stack file: %w", err) } defer os.Remove(tmpFile) cmd := exec.Command("docker", "stack", "deploy", "-c", tmpFile, stackName, ) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker stack deploy: %s: %w", strings.TrimSpace(string(output)), err) } log.Printf("deployed stack %s: %s", stackName, strings.TrimSpace(string(output))) // Force-restart the web service so Traefik picks up label changes immediately. // Traefik reads labels from running task containers, not service specs, so a // task restart is required for routing changes to take effect. forceCmd := exec.Command("docker", "service", "update", "--force", stackName+"_web") forceCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) if forceOut, forceErr := forceCmd.CombinedOutput(); forceErr != nil { log.Printf("warn: force-update %s_web: %s", stackName, strings.TrimSpace(string(forceOut))) } return nil } func (c *Client) RemoveStack(stackName string) error { cmd := exec.Command("docker", "stack", "rm", stackName) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker stack rm: %s: %w", strings.TrimSpace(string(output)), err) } log.Printf("removed stack %s: %s", stackName, strings.TrimSpace(string(output))) return nil } func (c *Client) StackExists(stackName string) (bool, error) { cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}") cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return false, fmt.Errorf("docker stack ls: %w", err) } for _, line := range strings.Split(string(output), "\n") { if strings.TrimSpace(line) == stackName { return true, nil } } return false, nil } // GetWebReplicas returns the desired replica count for _web. // Returns 0 if the service does not exist. func (c *Client) GetWebReplicas(stackName string) (int, error) { cmd := exec.Command("docker", "service", "inspect", "--format", "{{.Spec.Mode.Replicated.Replicas}}", stackName+"_web", ) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return 0, nil } n, err := strconv.Atoi(strings.TrimSpace(string(output))) if err != nil { return 0, nil } return n, nil } // ScaleStack sets the desired replica count for _web. func (c *Client) ScaleStack(stackName string, replicas int) error { svc := fmt.Sprintf("%s_web=%d", stackName, replicas) cmd := exec.Command("docker", "service", "scale", svc) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("scale stack %s: %s: %w", stackName, strings.TrimSpace(string(output)), err) } log.Printf("scaled %s_web to %d: %s", stackName, replicas, strings.TrimSpace(string(output))) return nil } // RestartStack force-restarts the web service, triggering a fresh container. func (c *Client) RestartStack(stackName string) error { cmd := exec.Command("docker", "service", "update", "--force", stackName+"_web") cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("restart stack %s: %s: %w", stackName, strings.TrimSpace(string(output)), err) } log.Printf("restarted %s_web: %s", stackName, strings.TrimSpace(string(output))) return nil }