feat: backend native executor implement
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
package hostnginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Snapshot is the complete host Nginx configuration before or after a switch.
|
||||
type Snapshot struct {
|
||||
Content []byte
|
||||
ActivePort int
|
||||
}
|
||||
|
||||
// Controller validates, atomically replaces, and reloads the current host Nginx configuration.
|
||||
type Controller struct {
|
||||
configPath string
|
||||
nginxExecutable string
|
||||
systemctlPath string
|
||||
nginxServiceName string
|
||||
runner commandRunner
|
||||
}
|
||||
|
||||
// NewController requires every external identifier to be supplied explicitly.
|
||||
func NewController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string) (*Controller, error) {
|
||||
return newController(configPath, nginxExecutable, systemctlPath, nginxServiceName, execRunner{})
|
||||
}
|
||||
|
||||
func newController(configPath string, nginxExecutable string, systemctlPath string, nginxServiceName string, runner commandRunner) (*Controller, error) {
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"host Nginx configuration", configPath},
|
||||
{"Nginx executable", nginxExecutable},
|
||||
{"systemctl executable", systemctlPath},
|
||||
} {
|
||||
if !filepath.IsAbs(entry.value) || filepath.Clean(entry.value) != entry.value {
|
||||
return nil, fmt.Errorf("%s must be a clean absolute path", entry.name)
|
||||
}
|
||||
}
|
||||
if nginxServiceName == "" || strings.TrimSpace(nginxServiceName) != nginxServiceName {
|
||||
return nil, errors.New("exact Nginx systemd service name is required")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("host Nginx command runner is required")
|
||||
}
|
||||
return &Controller{
|
||||
configPath: configPath,
|
||||
nginxExecutable: nginxExecutable,
|
||||
systemctlPath: systemctlPath,
|
||||
nginxServiceName: nginxServiceName,
|
||||
runner: runner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Read returns the complete current configuration and its active backend port.
|
||||
func (c *Controller) Read() (Snapshot, error) {
|
||||
info, err := os.Lstat(c.configPath)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("inspect host Nginx configuration %s: %w", c.configPath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return Snapshot{}, fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath)
|
||||
}
|
||||
content, err := os.ReadFile(c.configPath)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read host Nginx configuration %s: %w", c.configPath, err)
|
||||
}
|
||||
port, err := ActiveBackendPort(content)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return Snapshot{Content: content, ActivePort: port}, nil
|
||||
}
|
||||
|
||||
// Switch renders activePort from the current configuration, then validates and reloads Nginx.
|
||||
// It returns the durable pre-switch snapshot required for compensation.
|
||||
func (c *Controller) Switch(ctx context.Context, activePort int) (Snapshot, error) {
|
||||
previous, err := c.Read()
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if previous.ActivePort == activePort {
|
||||
return previous, nil
|
||||
}
|
||||
next, err := RenderBackendPort(previous.Content, activePort)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := c.replaceValidateReload(ctx, next, previous.Content); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
// Restore atomically restores a previously persisted complete configuration and reloads Nginx.
|
||||
func (c *Controller) Restore(ctx context.Context, snapshot Snapshot) error {
|
||||
if len(snapshot.Content) == 0 {
|
||||
return errors.New("host Nginx restore snapshot is empty")
|
||||
}
|
||||
parsedPort, err := ActiveBackendPort(snapshot.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate host Nginx restore snapshot: %w", err)
|
||||
}
|
||||
if parsedPort != snapshot.ActivePort {
|
||||
return fmt.Errorf("host Nginx restore snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort)
|
||||
}
|
||||
current, err := c.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(current.Content) == string(snapshot.Content) {
|
||||
return nil
|
||||
}
|
||||
return c.replaceValidateReload(ctx, snapshot.Content, current.Content)
|
||||
}
|
||||
|
||||
// Apply installs one previously persisted complete snapshot and reloads Nginx.
|
||||
func (c *Controller) Apply(ctx context.Context, snapshot Snapshot) error {
|
||||
if len(snapshot.Content) == 0 {
|
||||
return errors.New("host Nginx apply snapshot is empty")
|
||||
}
|
||||
parsedPort, err := ActiveBackendPort(snapshot.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate host Nginx apply snapshot: %w", err)
|
||||
}
|
||||
if parsedPort != snapshot.ActivePort {
|
||||
return fmt.Errorf("host Nginx apply snapshot port mismatch: content=%d metadata=%d", parsedPort, snapshot.ActivePort)
|
||||
}
|
||||
current, err := c.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.replaceValidateReload(ctx, snapshot.Content, current.Content)
|
||||
}
|
||||
|
||||
func (c *Controller) replaceValidateReload(ctx context.Context, desired []byte, rollback []byte) error {
|
||||
if err := c.atomicWrite(desired); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.runner.Run(ctx, c.nginxExecutable, "-t"); err != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("validate host Nginx configuration: %w", err),
|
||||
c.restoreAfterFailure(ctx, rollback),
|
||||
)
|
||||
}
|
||||
if err := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName); err != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("reload host Nginx service %s: %w", c.nginxServiceName, err),
|
||||
c.restoreAfterFailure(ctx, rollback),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) restoreAfterFailure(ctx context.Context, content []byte) error {
|
||||
if err := c.atomicWrite(content); err != nil {
|
||||
return fmt.Errorf("restore host Nginx configuration after failure: %w", err)
|
||||
}
|
||||
validateErr := c.runner.Run(ctx, c.nginxExecutable, "-t")
|
||||
reloadErr := c.runner.Run(ctx, c.systemctlPath, "reload", "--", c.nginxServiceName)
|
||||
return errors.Join(
|
||||
wrapError("validate restored host Nginx configuration", validateErr),
|
||||
wrapError("reload restored host Nginx configuration", reloadErr),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Controller) atomicWrite(content []byte) error {
|
||||
info, err := os.Lstat(c.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect host Nginx configuration before replacement: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("host Nginx configuration is not a direct regular file: %s", c.configPath)
|
||||
}
|
||||
parent := filepath.Dir(c.configPath)
|
||||
temporary, err := os.CreateTemp(parent, ".yms-daemon-nginx-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary host Nginx configuration: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(info.Mode().Perm()); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("set temporary host Nginx configuration permissions: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(content); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("write temporary host Nginx configuration: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("flush temporary host Nginx configuration: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary host Nginx configuration: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, c.configPath); err != nil {
|
||||
return fmt.Errorf("replace host Nginx configuration: %w", err)
|
||||
}
|
||||
return syncDirectory(parent)
|
||||
}
|
||||
|
||||
type commandRunner interface {
|
||||
Run(context.Context, string, ...string) error
|
||||
}
|
||||
|
||||
type execRunner struct{}
|
||||
|
||||
func (execRunner) Run(ctx context.Context, executable string, arguments ...string) error {
|
||||
output, err := exec.CommandContext(ctx, executable, arguments...).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
detail := strings.TrimSpace(string(output))
|
||||
if detail == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %s", err, detail)
|
||||
}
|
||||
|
||||
func wrapError(message string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", message, err)
|
||||
}
|
||||
|
||||
func syncDirectory(directory string) error {
|
||||
file, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open host Nginx configuration directory: %w", err)
|
||||
}
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(syncErr, closeErr); err != nil {
|
||||
return fmt.Errorf("flush host Nginx configuration directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user