Add Windows Service & MSI builds

This commit is contained in:
Ben Johnson
2021-02-19 16:00:03 -07:00
parent e2cbd5fb63
commit c5390dec1d
7 changed files with 296 additions and 42 deletions

View File

@@ -2,15 +2,18 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log" "log"
"net/url" "net/url"
"os" "os"
"os/signal"
"os/user" "os/user"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@@ -25,14 +28,17 @@ var (
Version = "(development build)" Version = "(development build)"
) )
// errStop is a terminal error for indicating program should quit.
var errStop = errors.New("stop")
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
m := NewMain() m := NewMain()
if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp { if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp || err == errStop {
os.Exit(1) os.Exit(1)
} else if err != nil { } else if err != nil {
fmt.Fprintln(os.Stderr, err) log.Println(err)
os.Exit(1) os.Exit(1)
} }
} }
@@ -47,6 +53,14 @@ func NewMain() *Main {
// Run executes the program. // Run executes the program.
func (m *Main) Run(ctx context.Context, args []string) (err error) { func (m *Main) Run(ctx context.Context, args []string) (err error) {
// Execute replication command if running as a Windows service.
if isService, err := isWindowsService(); err != nil {
return err
} else if isService {
return runWindowsService(ctx)
}
// Extract command name.
var cmd string var cmd string
if len(args) > 0 { if len(args) > 0 {
cmd, args = args[0], args[1:] cmd, args = args[0], args[1:]
@@ -58,7 +72,28 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
case "generations": case "generations":
return (&GenerationsCommand{}).Run(ctx, args) return (&GenerationsCommand{}).Run(ctx, args)
case "replicate": case "replicate":
return (&ReplicateCommand{}).Run(ctx, args) c := NewReplicateCommand()
if err := c.ParseFlags(ctx, args); err != nil {
return err
}
// Setup signal handler.
ctx, cancel := context.WithCancel(ctx)
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() { <-ch; cancel() }()
if err := c.Run(ctx); err != nil {
return err
}
// Wait for signal to stop program.
<-ctx.Done()
signal.Reset()
// Gracefully close.
return c.Close()
case "restore": case "restore":
return (&RestoreCommand{}).Run(ctx, args) return (&RestoreCommand{}).Run(ctx, args)
case "snapshots": case "snapshots":
@@ -222,8 +257,7 @@ func ParseReplicaURL(s string) (scheme, host, urlpath string, err error) {
// isURL returns true if s can be parsed and has a scheme. // isURL returns true if s can be parsed and has a scheme.
func isURL(s string) bool { func isURL(s string) bool {
u, err := url.Parse(s) return regexp.MustCompile(`^\w+:\/\/`).MatchString(s)
return err == nil && u.Scheme != ""
} }
// ReplicaType returns the type based on the type field or extracted from the URL. // ReplicaType returns the type based on the type field or extracted from the URL.
@@ -242,7 +276,7 @@ func DefaultConfigPath() string {
if v := os.Getenv("LITESTREAM_CONFIG"); v != "" { if v := os.Getenv("LITESTREAM_CONFIG"); v != "" {
return v return v
} }
return "/etc/litestream.yml" return defaultConfigPath
} }
func registerConfigFlag(fs *flag.FlagSet, p *string) { func registerConfigFlag(fs *flag.FlagSet, p *string) {

View File

@@ -0,0 +1,17 @@
// +build !windows
package main
import (
"context"
)
const defaultConfigPath = "/etc/litestream.yml"
func isWindowsService() (bool, error) {
return false, nil
}
func runWindowsService(ctx context.Context) error {
panic("cannot run windows service as unix process")
}

View File

@@ -0,0 +1,105 @@
// +build windows
package main
import (
"context"
"io"
"log"
"os"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
)
const defaultConfigPath = `C:\Litestream\litestream.yml`
// serviceName is the Windows Service name.
const serviceName = "Litestream"
// isWindowsService returns true if currently executing within a Windows service.
func isWindowsService() (bool, error) {
return svc.IsWindowsService()
}
func runWindowsService(ctx context.Context) error {
// Attempt to install new log service. This will fail if already installed.
// We don't log the error because we don't have anywhere to log until we open the log.
_ = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info)
elog, err := eventlog.Open(serviceName)
if err != nil {
return err
}
defer elog.Close()
// Set eventlog as log writer while running.
log.SetOutput((*eventlogWriter)(elog))
defer log.SetOutput(os.Stderr)
log.Print("Litestream service starting")
if err := svc.Run(serviceName, &windowsService{ctx: ctx}); err != nil {
return errStop
}
log.Print("Litestream service stopped")
return nil
}
// windowsService is an interface adapter for svc.Handler.
type windowsService struct {
ctx context.Context
}
func (s *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, statusCh chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
var err error
// Notify Windows that the service is starting up.
statusCh <- svc.Status{State: svc.StartPending}
// Instantiate replication command and load configuration.
c := NewReplicateCommand()
if c.Config, err = ReadConfigFile(DefaultConfigPath()); err != nil {
log.Printf("cannot load configuration: %s", err)
return true, 1
}
// Execute replication command.
if err := c.Run(s.ctx); err != nil {
log.Printf("cannot replicate: %s", err)
statusCh <- svc.Status{State: svc.StopPending}
return true, 2
}
// Notify Windows that the service is now running.
statusCh <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop}
for {
select {
case req := <-r:
switch req.Cmd {
case svc.Stop:
c.Close()
statusCh <- svc.Status{State: svc.StopPending}
return false, windows.NO_ERROR
case svc.Interrogate:
statusCh <- req.CurrentStatus
default:
log.Printf("Litestream service received unexpected change request cmd: %d", req.Cmd)
}
}
}
}
// Ensure implementation implements io.Writer interface.
var _ io.Writer = (*eventlogWriter)(nil)
// eventlogWriter is an adapter for using eventlog.Log as an io.Writer.
type eventlogWriter eventlog.Log
func (w *eventlogWriter) Write(p []byte) (n int, err error) {
elog := (*eventlog.Log)(w)
return 0, elog.Info(1, string(p))
}

View File

@@ -10,7 +10,6 @@ import (
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
"os" "os"
"os/signal"
"time" "time"
"github.com/benbjohnson/litestream" "github.com/benbjohnson/litestream"
@@ -27,8 +26,12 @@ type ReplicateCommand struct {
DBs []*litestream.DB DBs []*litestream.DB
} }
// Run loads all databases specified in the configuration. func NewReplicateCommand() *ReplicateCommand {
func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) { return &ReplicateCommand{}
}
// ParseFlags parses the CLI flags and loads the configuration file.
func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err error) {
fs := flag.NewFlagSet("litestream-replicate", flag.ContinueOnError) fs := flag.NewFlagSet("litestream-replicate", flag.ContinueOnError)
tracePath := fs.String("trace", "", "trace path") tracePath := fs.String("trace", "", "trace path")
registerConfigFlag(fs, &c.ConfigPath) registerConfigFlag(fs, &c.ConfigPath)
@@ -38,7 +41,6 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) {
} }
// Load configuration or use CLI args to build db/replica. // Load configuration or use CLI args to build db/replica.
var config Config
if fs.NArg() == 1 { if fs.NArg() == 1 {
return fmt.Errorf("must specify at least one replica URL for %s", fs.Arg(0)) return fmt.Errorf("must specify at least one replica URL for %s", fs.Arg(0))
} else if fs.NArg() > 1 { } else if fs.NArg() > 1 {
@@ -49,10 +51,9 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) {
SyncInterval: 1 * time.Second, SyncInterval: 1 * time.Second,
}) })
} }
config.DBs = []*DBConfig{dbConfig} c.Config.DBs = []*DBConfig{dbConfig}
} else if c.ConfigPath != "" { } else if c.ConfigPath != "" {
config, err = ReadConfigFile(c.ConfigPath) if c.Config, err = ReadConfigFile(c.ConfigPath); err != nil {
if err != nil {
return err return err
} }
} else { } else {
@@ -69,21 +70,20 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) {
litestream.Tracef = log.New(f, "", log.LstdFlags|log.LUTC|log.Lshortfile).Printf litestream.Tracef = log.New(f, "", log.LstdFlags|log.LUTC|log.Lshortfile).Printf
} }
// Setup signal handler. return nil
ctx, cancel := context.WithCancel(ctx) }
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() { <-ch; cancel() }()
// Run loads all databases specified in the configuration.
func (c *ReplicateCommand) Run(ctx context.Context) (err error) {
// Display version information. // Display version information.
fmt.Printf("litestream %s\n", Version) log.Printf("litestream %s", Version)
if len(config.DBs) == 0 { if len(c.Config.DBs) == 0 {
fmt.Println("no databases specified in configuration") log.Println("no databases specified in configuration")
} }
for _, dbConfig := range config.DBs { for _, dbConfig := range c.Config.DBs {
db, err := newDBFromConfig(&config, dbConfig) db, err := newDBFromConfig(&c.Config, dbConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -97,41 +97,31 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) {
// Notify user that initialization is done. // Notify user that initialization is done.
for _, db := range c.DBs { for _, db := range c.DBs {
fmt.Printf("initialized db: %s\n", db.Path()) log.Printf("initialized db: %s", db.Path())
for _, r := range db.Replicas { for _, r := range db.Replicas {
switch r := r.(type) { switch r := r.(type) {
case *litestream.FileReplica: case *litestream.FileReplica:
fmt.Printf("replicating to: name=%q type=%q path=%q\n", r.Name(), r.Type(), r.Path()) log.Printf("replicating to: name=%q type=%q path=%q", r.Name(), r.Type(), r.Path())
case *s3.Replica: case *s3.Replica:
fmt.Printf("replicating to: name=%q type=%q bucket=%q path=%q region=%q\n", r.Name(), r.Type(), r.Bucket, r.Path, r.Region) log.Printf("replicating to: name=%q type=%q bucket=%q path=%q region=%q", r.Name(), r.Type(), r.Bucket, r.Path, r.Region)
default: default:
fmt.Printf("replicating to: name=%q type=%q\n", r.Name(), r.Type()) log.Printf("replicating to: name=%q type=%q", r.Name(), r.Type())
} }
} }
} }
// Serve metrics over HTTP if enabled. // Serve metrics over HTTP if enabled.
if config.Addr != "" { if c.Config.Addr != "" {
_, port, _ := net.SplitHostPort(config.Addr) _, port, _ := net.SplitHostPort(c.Config.Addr)
fmt.Printf("serving metrics on http://localhost:%s/metrics\n", port) log.Printf("serving metrics on http://localhost:%s/metrics", port)
go func() { go func() {
http.Handle("/metrics", promhttp.Handler()) http.Handle("/metrics", promhttp.Handler())
if err := http.ListenAndServe(config.Addr, nil); err != nil { if err := http.ListenAndServe(c.Config.Addr, nil); err != nil {
log.Printf("cannot start metrics server: %s", err) log.Printf("cannot start metrics server: %s", err)
} }
}() }()
} }
// Wait for signal to stop program.
<-ctx.Done()
signal.Reset()
// Gracefully close
if err := c.Close(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return nil return nil
} }
@@ -139,12 +129,13 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) {
func (c *ReplicateCommand) Close() (err error) { func (c *ReplicateCommand) Close() (err error) {
for _, db := range c.DBs { for _, db := range c.DBs {
if e := db.SoftClose(); e != nil { if e := db.SoftClose(); e != nil {
fmt.Printf("error closing db: path=%s err=%s\n", db.Path(), e) log.Printf("error closing db: path=%s err=%s", db.Path(), e)
if err == nil { if err == nil {
err = e err = e
} }
} }
} }
// TODO(windows): Clear DBs
return err return err
} }

17
etc/build.ps1 Normal file
View File

@@ -0,0 +1,17 @@
[CmdletBinding()]
Param (
[Parameter(Mandatory = $true)]
[String] $Version
)
$ErrorActionPreference = "Stop"
# Update working directory.
Push-Location $PSScriptRoot
Trap {
Pop-Location
}
Invoke-Expression "candle.exe -nologo -arch x64 -ext WixUtilExtension -out litestream.wixobj -dVersion=`"$Version`" litestream.wxs"
Invoke-Expression "light.exe -nologo -spdb -ext WixUtilExtension -out `"litestream-${Version}.msi`" litestream.wixobj"
Pop-Location

89
etc/litestream.wxs Normal file
View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix
xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"
>
<?if $(sys.BUILDARCH)=x64 ?>
<?define PlatformProgramFiles = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFiles = "ProgramFilesFolder" ?>
<?endif ?>
<Product
Id="*"
UpgradeCode="5371367e-58b3-4e52-be0d-46945eb71ce6"
Name="Litestream"
Version="$(var.Version)"
Manufacturer="Litestream"
Language="1033"
Codepage="1252"
>
<Package
Id="*"
Manufacturer="Litestream"
InstallScope="perMachine"
InstallerVersion="500"
Description="Litestream $(var.Version) installer"
Compressed="yes"
/>
<Media Id="1" Cabinet="litestream.cab" EmbedCab="yes"/>
<MajorUpgrade
Schedule="afterInstallInitialize"
DowngradeErrorMessage="A later version of [ProductName] is already installed. Setup will now exit."
/>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="$(var.PlatformProgramFiles)">
<Directory Id="APPLICATIONROOTDIRECTORY" Name="Litestream"/>
</Directory>
</Directory>
<ComponentGroup Id="Files">
<Component Directory="APPLICATIONROOTDIRECTORY">
<File
Id="litestream.exe"
Name="litestream.exe"
Source="litestream.exe"
KeyPath="yes"
/>
<ServiceInstall
Id="InstallService"
Name="Litestream"
DisplayName="Litestream"
Description="Replicates SQLite databases"
ErrorControl="normal"
Start="auto"
Type="ownProcess"
>
<util:ServiceConfig
FirstFailureActionType="restart"
SecondFailureActionType="restart"
ThirdFailureActionType="restart"
RestartServiceDelayInSeconds="60"
/>
<ServiceDependency Id="wmiApSrv" />
</ServiceInstall>
<ServiceControl
Id="ServiceStateControl"
Name="Litestream"
Remove="uninstall"
Start="install"
Stop="both"
/>
<util:EventSource
Log="Application"
Name="Litestream"
EventMessageFile="%SystemRoot%\System32\EventCreate.exe"
/>
</Component>
</ComponentGroup>
<Feature Id="DefaultFeature" Level="1">
<ComponentGroupRef Id="Files" />
</Feature>
</Product>
</Wix>

1
go.mod
View File

@@ -8,5 +8,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.5 github.com/mattn/go-sqlite3 v1.14.5
github.com/pierrec/lz4/v4 v4.1.3 github.com/pierrec/lz4/v4 v4.1.3
github.com/prometheus/client_golang v1.9.0 github.com/prometheus/client_golang v1.9.0
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )