7 changed files with 553 additions and 7 deletions
@ -0,0 +1,114 @@ |
|||
/* |
|||
Copyright © 2022 Nicolas MASSE |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in |
|||
all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
|||
THE SOFTWARE. |
|||
*/ |
|||
package cmd |
|||
|
|||
import ( |
|||
"fmt" |
|||
"os" |
|||
|
|||
mqttArchiver "github.com/nmasse-itix/mqtt-archiver" |
|||
"github.com/spf13/cobra" |
|||
"github.com/spf13/viper" |
|||
) |
|||
|
|||
// listCmd represents the list command
|
|||
var listCmd = &cobra.Command{ |
|||
Use: "list", |
|||
Short: "List available archives", |
|||
Long: `TODO`, |
|||
Run: func(cmd *cobra.Command, args []string) { |
|||
ok := true |
|||
if viper.GetString("s3.endpoint") == "" { |
|||
logger.Println("No S3 endpoint defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.accessKey") == "" { |
|||
logger.Println("No S3 access key defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.secretKey") == "" { |
|||
logger.Println("No S3 secret key defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.bucket") == "" { |
|||
logger.Println("No S3 bucket name defined in configuration") |
|||
ok = false |
|||
} |
|||
if from.time.IsZero() { |
|||
logger.Println("Please specify the beginning of the replay period") |
|||
ok = false |
|||
} |
|||
if to.time.IsZero() { |
|||
to.Set("now") |
|||
} |
|||
if !ok { |
|||
os.Exit(1) |
|||
} |
|||
|
|||
config := mqttArchiver.ReplayerConfig{ |
|||
S3Config: mqttArchiver.S3Config{ |
|||
Endpoint: viper.GetString("s3.endpoint"), |
|||
AccessKey: viper.GetString("s3.accessKey"), |
|||
SecretKey: viper.GetString("s3.secretKey"), |
|||
UseSSL: viper.GetBool("s3.ssl"), |
|||
BucketName: viper.GetString("s3.bucket"), |
|||
}, |
|||
WorkingDir: viper.GetString("workingDir"), |
|||
Logger: logger, |
|||
Follow: follow, |
|||
From: from.time, |
|||
To: to.time, |
|||
} |
|||
replayer, err := mqttArchiver.NewReplayer(config) |
|||
if err != nil { |
|||
logger.Fatalln(err) |
|||
} |
|||
|
|||
files := make(chan mqttArchiver.Archive) |
|||
errors := make(chan error) |
|||
eol := make(chan struct{}) |
|||
|
|||
go replayer.ListArchives(files, errors, eol) |
|||
|
|||
for { |
|||
select { |
|||
case <-eol: |
|||
return |
|||
case err := <-errors: |
|||
logger.Println(err) |
|||
case file := <-files: |
|||
file.Reader.Close() |
|||
fmt.Println(file.FileName) |
|||
} |
|||
} |
|||
|
|||
}, |
|||
} |
|||
|
|||
func init() { |
|||
listCmd.Flags().BoolVarP(&follow, "follow", "f", false, "list archives as they are produced") |
|||
listCmd.Flags().Var(&from, "from", "beginning of list period") |
|||
listCmd.Flags().Var(&to, "to", "end of list period") |
|||
|
|||
rootCmd.AddCommand(listCmd) |
|||
|
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
/* |
|||
Copyright © 2022 Nicolas MASSE |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in |
|||
all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
|||
THE SOFTWARE. |
|||
*/ |
|||
package cmd |
|||
|
|||
import ( |
|||
"os" |
|||
"os/signal" |
|||
"syscall" |
|||
|
|||
mqttArchiver "github.com/nmasse-itix/mqtt-archiver" |
|||
"github.com/spf13/cobra" |
|||
"github.com/spf13/viper" |
|||
) |
|||
|
|||
// listCmd represents the list command
|
|||
var replayCmd = &cobra.Command{ |
|||
Use: "replay", |
|||
Short: "Replays event from archives to MQTT broker", |
|||
Long: `TODO`, |
|||
Run: func(cmd *cobra.Command, args []string) { |
|||
// Each main feature gets its own default client id to prevent the replay
|
|||
// feature from colliding with the archive function
|
|||
viper.SetDefault("mqtt.clientId", "mqtt-archiver-replay") |
|||
|
|||
ok := true |
|||
if viper.GetString("s3.endpoint") == "" { |
|||
logger.Println("No S3 endpoint defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.accessKey") == "" { |
|||
logger.Println("No S3 access key defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.secretKey") == "" { |
|||
logger.Println("No S3 secret key defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("s3.bucket") == "" { |
|||
logger.Println("No S3 bucket name defined in configuration") |
|||
ok = false |
|||
} |
|||
if viper.GetString("mqtt.broker") == "" { |
|||
logger.Println("No MQTT broker defined in configuration") |
|||
ok = false |
|||
} |
|||
if from.time.IsZero() { |
|||
logger.Println("Please specify the beginning of the replay period") |
|||
ok = false |
|||
} |
|||
if to.time.IsZero() { |
|||
to.Set("now") |
|||
} |
|||
if !ok { |
|||
os.Exit(1) |
|||
} |
|||
|
|||
config := mqttArchiver.ReplayerConfig{ |
|||
S3Config: mqttArchiver.S3Config{ |
|||
Endpoint: viper.GetString("s3.endpoint"), |
|||
AccessKey: viper.GetString("s3.accessKey"), |
|||
SecretKey: viper.GetString("s3.secretKey"), |
|||
UseSSL: viper.GetBool("s3.ssl"), |
|||
BucketName: viper.GetString("s3.bucket"), |
|||
}, |
|||
MqttConfig: mqttArchiver.MqttConfig{ |
|||
BrokerURL: viper.GetString("mqtt.broker"), |
|||
Username: viper.GetString("mqtt.username"), |
|||
Password: viper.GetString("mqtt.password"), |
|||
ClientID: viper.GetString("mqtt.clientId"), |
|||
Timeout: viper.GetDuration("mqtt.timeout"), |
|||
GracePeriod: viper.GetDuration("mqtt.gracePeriod"), |
|||
}, |
|||
WorkingDir: viper.GetString("workingDir"), |
|||
Logger: logger, |
|||
TopicPrefix: prefix, |
|||
From: from.time, |
|||
To: to.time, |
|||
} |
|||
replayer, err := mqttArchiver.NewReplayer(config) |
|||
if err != nil { |
|||
logger.Fatalln(err) |
|||
} |
|||
|
|||
go func() { |
|||
// trap SIGINT and SIGTEM to gracefully stop
|
|||
sigs := make(chan os.Signal, 1) |
|||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) |
|||
|
|||
// Wait for SIGTERM or SIGINT
|
|||
sig := <-sigs |
|||
logger.Printf("Received signal %s", sig) |
|||
replayer.StopReplay() |
|||
}() |
|||
|
|||
logger.Println("Starting the replay process...") |
|||
replayer.StartReplay() |
|||
}, |
|||
} |
|||
|
|||
func init() { |
|||
replayCmd.Flags().Var(&from, "from", "beginning of replay period") |
|||
replayCmd.Flags().Var(&to, "to", "end of replay period") |
|||
replayCmd.Flags().StringVarP(&prefix, "prefix", "p", "", "prefix MQTT topic with the supplied string") |
|||
|
|||
rootCmd.AddCommand(replayCmd) |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
/* |
|||
Copyright © 2022 Nicolas MASSE |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in |
|||
all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
|||
THE SOFTWARE. |
|||
*/ |
|||
package lib |
|||
|
|||
import ( |
|||
"bufio" |
|||
"compress/gzip" |
|||
"context" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io" |
|||
"log" |
|||
"os" |
|||
"path" |
|||
"sync" |
|||
"time" |
|||
|
|||
mqtt "github.com/eclipse/paho.mqtt.golang" |
|||
"github.com/minio/minio-go/v7" |
|||
"github.com/minio/minio-go/v7/pkg/credentials" |
|||
) |
|||
|
|||
const ( |
|||
// Maximum length of a JSON entry (bytes)
|
|||
MaxTokenSize int = 1024 * 1024 |
|||
) |
|||
|
|||
// A ReplayerConfig holds the configuration data of a Replayer
|
|||
type ReplayerConfig struct { |
|||
S3Config S3Config // credentials to connect to S3
|
|||
MqttConfig MqttConfig // credentials to connect to MQTT
|
|||
WorkingDir string // location to store JSON files
|
|||
Follow bool // when listing archives, wait for new archives as they are produced
|
|||
Logger *log.Logger // a logger
|
|||
From time.Time // begining of the replay period
|
|||
To time.Time // end of the replay period
|
|||
TopicPrefix string // prefix topic with this string
|
|||
} |
|||
|
|||
// A Replayer replays events from JSON archives to the MQTT broker
|
|||
type Replayer struct { |
|||
Config ReplayerConfig // the replayer public configuration
|
|||
wg sync.WaitGroup // a wait group to keep track of each running go routine
|
|||
s3Client *minio.Client // the s3 client
|
|||
mqttClient mqtt.Client // the MQTT client
|
|||
done chan bool // a channel to signal the replayer it must ends gracefully
|
|||
} |
|||
|
|||
// An Archive represents an archive that has been opened (file descriptor is open).
|
|||
// Any method receiving this structure is responsible for calling Reader.Close().
|
|||
type Archive struct { |
|||
Timestamp time.Time |
|||
Reader io.ReadCloser |
|||
FileName string |
|||
} |
|||
|
|||
// NewReplayer builds a replayer by its public configuration
|
|||
func NewReplayer(c ReplayerConfig) (*Replayer, error) { |
|||
var replayer Replayer = Replayer{ |
|||
Config: c, |
|||
} |
|||
|
|||
var err error |
|||
replayer.s3Client, err = minio.New(replayer.Config.S3Config.Endpoint, &minio.Options{ |
|||
Creds: credentials.NewStaticV4(replayer.Config.S3Config.AccessKey, replayer.Config.S3Config.SecretKey, ""), |
|||
Secure: replayer.Config.S3Config.UseSSL, |
|||
}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
ctx := context.Background() |
|||
exists, err := replayer.s3Client.BucketExists(ctx, replayer.Config.S3Config.BucketName) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
if !exists { |
|||
return nil, fmt.Errorf("s3 bucket does not exist") |
|||
} |
|||
|
|||
// There are two consumers of this channel (ListArchives and StartReplay)
|
|||
replayer.done = make(chan bool, 2) |
|||
|
|||
return &replayer, nil |
|||
} |
|||
|
|||
// ListArchives sends a list of available archives (and potential errors)
|
|||
// through the files (and errors) channels.
|
|||
// If replayer.Config.Follow is false, the eol channel is used to signal
|
|||
// the end of the list.
|
|||
func (replayer *Replayer) ListArchives(files chan Archive, errors chan error, eol chan struct{}) { |
|||
replayer.wg.Add(1) |
|||
defer replayer.wg.Done() |
|||
|
|||
i := replayer.Config.From |
|||
|
|||
var end time.Time = replayer.Config.To |
|||
var loose bool = false |
|||
main: |
|||
for { |
|||
var retries = 5 |
|||
for i.Before(end) { |
|||
fd, err := replayer.OpenArchive(i) |
|||
if err != nil { |
|||
errors <- err |
|||
} else if fd != nil { |
|||
var archive Archive = Archive{ |
|||
Timestamp: i, |
|||
FileName: i.Format(logfileFormat), |
|||
Reader: fd, |
|||
} |
|||
files <- archive |
|||
} else if loose && retries > 0 { |
|||
// When following archive production, the productor might not
|
|||
// had the time to rotate its archives. Retry for a few seconds...
|
|||
retries-- |
|||
time.Sleep(time.Second) |
|||
continue |
|||
} |
|||
|
|||
i = i.Add(rotationInterval) |
|||
} |
|||
|
|||
if !replayer.Config.Follow { |
|||
eol <- struct{}{} // Signal end of list
|
|||
break |
|||
} |
|||
|
|||
// End or continue after a one second pause
|
|||
select { |
|||
case <-replayer.done: |
|||
break main |
|||
case <-time.After(time.Second): |
|||
|
|||
} |
|||
|
|||
end = time.Now().UTC() |
|||
loose = true |
|||
} |
|||
} |
|||
|
|||
// StopReplay initiates a graceful stop of the replay process.
|
|||
func (replayer *Replayer) StopReplay() { |
|||
replayer.done <- true |
|||
replayer.done <- true |
|||
replayer.wg.Wait() |
|||
} |
|||
|
|||
// StartReplay starts the replay process.
|
|||
func (replayer *Replayer) StartReplay() { |
|||
var err error |
|||
|
|||
replayer.wg.Add(1) |
|||
defer replayer.wg.Done() |
|||
|
|||
// initialize the MQTT library
|
|||
SetMqttLogger(replayer.Config.Logger) |
|||
replayer.mqttClient, err = NewMqttClient(replayer.Config.MqttConfig, true) |
|||
if err != nil { |
|||
replayer.Config.Logger.Println(err) |
|||
return |
|||
} |
|||
|
|||
files := make(chan Archive) |
|||
errors := make(chan error) |
|||
eol := make(chan struct{}) |
|||
go replayer.ListArchives(files, errors, eol) |
|||
|
|||
main: |
|||
for { |
|||
var archive Archive |
|||
select { |
|||
case <-eol: |
|||
break main |
|||
case err := <-errors: |
|||
replayer.Config.Logger.Println(err) |
|||
continue |
|||
case archive = <-files: |
|||
err = replayer.ReplayArchive(archive) |
|||
if err != nil { |
|||
replayer.Config.Logger.Println(err) |
|||
} |
|||
case <-replayer.done: |
|||
break main |
|||
} |
|||
|
|||
} |
|||
} |
|||
|
|||
// ReplayArchive replays a specific archive.
|
|||
func (replayer *Replayer) ReplayArchive(archive Archive) error { |
|||
defer archive.Reader.Close() |
|||
|
|||
replayer.Config.Logger.Printf("Replaying archive %s...", archive.FileName) |
|||
|
|||
var buffer []byte = make([]byte, MaxTokenSize) |
|||
scanner := bufio.NewScanner(archive.Reader) |
|||
scanner.Buffer(buffer, MaxTokenSize) |
|||
for scanner.Scan() { |
|||
var evt EventLogEntry |
|||
err := json.Unmarshal(scanner.Bytes(), &evt) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
var topic string = evt.Topic |
|||
if replayer.Config.TopicPrefix != "" { |
|||
topic = replayer.Config.TopicPrefix + topic |
|||
} |
|||
token := replayer.mqttClient.Publish(topic, MQTT_QOS_2, false, evt.Payload) |
|||
if !token.WaitTimeout(replayer.Config.MqttConfig.Timeout) { |
|||
return fmt.Errorf("timeout") |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// OpenArchive opens the archive containing to a specific point in time.
|
|||
// It handle the following cases:
|
|||
// - .json file in the working directory
|
|||
// - .json.gz file in the working directory
|
|||
// - .json.gz file in the S3 bucket
|
|||
func (replayer *Replayer) OpenArchive(moment time.Time) (io.ReadCloser, error) { |
|||
fileName := moment.Format(logfileFormat) |
|||
logFilePath := path.Join(replayer.Config.WorkingDir, fileName) |
|||
fd, err := os.OpenFile(logFilePath, os.O_RDONLY, 0755) |
|||
if err == nil { |
|||
return fd, nil |
|||
} else if !errors.Is(err, os.ErrNotExist) { |
|||
return nil, err |
|||
} |
|||
|
|||
fileName += ".gz" |
|||
fd, err = os.OpenFile(logFilePath, os.O_RDONLY, 0755) |
|||
if err == nil { |
|||
return gzip.NewReader(fd) |
|||
} else if !errors.Is(err, os.ErrNotExist) { |
|||
return nil, err |
|||
} |
|||
|
|||
ctx := context.Background() |
|||
s3FileName := path.Join(string(fileName[0:4]), fileName) |
|||
obj, err := replayer.s3Client.GetObject(ctx, replayer.Config.S3Config.BucketName, s3FileName, minio.GetObjectOptions{}) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
_, err = obj.Stat() |
|||
if err == nil { |
|||
return gzip.NewReader(obj) |
|||
} |
|||
|
|||
var s3err minio.ErrorResponse |
|||
if errors.As(err, &s3err) && s3err.Code == "NoSuchKey" { |
|||
return nil, nil |
|||
} |
|||
|
|||
return nil, err |
|||
} |
|||
Loading…
Reference in new issue