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 }