Initial commit
This commit is contained in:
commit
23e637ebc3
17 changed files with 758 additions and 0 deletions
40
.gitea/workflows/main.yml
Normal file
40
.gitea/workflows/main.yml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
name: Build and release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: 'Version'
|
||||||
|
required: true
|
||||||
|
default: 'v0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: [moon, amd64]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
|
||||||
|
# - name: Run tests
|
||||||
|
# run: task test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
task build VERSION=${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
- uses: actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
direction: upload
|
||||||
|
tag: ${{ github.event.inputs.version }}
|
||||||
|
title: ${{ github.event.inputs.version }}
|
||||||
|
url: https://gitea.ceperka.net
|
||||||
|
release-dir: bin/
|
||||||
|
release-notes-assistant: true
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
bin/
|
34
README.md
Normal file
34
README.md
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Incus keeper
|
||||||
|
|
||||||
|
This project is covering backup and sync scenarios for Incus instances.
|
||||||
|
|
||||||
|
* Backups instances into Restic repository
|
||||||
|
* Syncs instances to another incus instance
|
||||||
|
* Backs up incus itself
|
||||||
|
|
||||||
|
## KV
|
||||||
|
|
||||||
|
Functions of Sentinel depends on user KV values configured on each instance. Here is what you can configure:
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
| -------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
|
| user.backup | false | true/false, if true, regular backup job into Restic is performed |
|
||||||
|
| user.sync | false | true/false, if true, regular sync job into Restic is performed |
|
||||||
|
| user.backup-notify-url | "" | Call this URL when backup is done |
|
||||||
|
| user.sync-notify-url | "" | Call this URL when sync is done |
|
||||||
|
| user.backup-schedule | 0 6 * * * | Cron-like line for backup scheduling |
|
||||||
|
| user.sync-schedule | 0 6 * * * | Cron-like line for sync scheduling |
|
||||||
|
| user.sync-target-remote | "" | Sync's target host (needs to be configured in Incus) |
|
||||||
|
| user.sync-target-pool | pool0 | Target's storage pool |
|
||||||
|
| user.sync-target-instance-suffix | -cold | Instance name suffix at the target side |
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
Only one backup or sync job can run the same time. There is internal queue of jobs that
|
||||||
|
is picked up one by one. Cli commands sync and backup are independent from this queue.
|
||||||
|
|
||||||
|
Restic needs two environment variables to be set:
|
||||||
|
|
||||||
|
* RESTIC_PASSWORD
|
||||||
|
* RESTIC_REPOSITORY
|
||||||
|
|
27
Taskfile.yml
Normal file
27
Taskfile.yml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# https://taskfile.dev
|
||||||
|
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
VERSION: v0
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build-bin:
|
||||||
|
cmds:
|
||||||
|
- GOARCH={{ .GOARCH }} go build -o bin/incus-sentinel.{{ .VERSION }}.linux.{{ .GOARCH }} main.go
|
||||||
|
silent: false
|
||||||
|
build:
|
||||||
|
cmds:
|
||||||
|
- mkdir -p bin
|
||||||
|
- task: build-bin
|
||||||
|
vars:
|
||||||
|
GOARCH: arm64
|
||||||
|
- task: build-bin
|
||||||
|
vars:
|
||||||
|
GOARCH: amd64
|
||||||
|
deploy-racker:
|
||||||
|
cmds:
|
||||||
|
- task: build
|
||||||
|
- scp bin/incus-sentinel.{{ .VERSION }}.linux.arm64 racker:/usr/local/bin/incus-sentinel.tmp
|
||||||
|
- ssh racker mv /usr/local/bin/incus-sentinel.tmp /usr/local/bin/incus-sentinel
|
||||||
|
- ssh racker systemctl restart incus-sentinel
|
BIN
bin/incus-sentinel.linux.amd64
Executable file
BIN
bin/incus-sentinel.linux.amd64
Executable file
Binary file not shown.
BIN
bin/incus-sentinel.linux.arm64
Executable file
BIN
bin/incus-sentinel.linux.arm64
Executable file
Binary file not shown.
BIN
bin/incus-sentinel.v0.linux.amd64
Executable file
BIN
bin/incus-sentinel.v0.linux.amd64
Executable file
Binary file not shown.
BIN
bin/incus-sentinel.v0.linux.arm64
Executable file
BIN
bin/incus-sentinel.v0.linux.arm64
Executable file
Binary file not shown.
37
dummy/main.go
Normal file
37
dummy/main.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package dummy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.ceperka.net/rosti/incus-sentinel/incus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DummyDriver struct{}
|
||||||
|
|
||||||
|
func NewDummyDriver() *DummyDriver {
|
||||||
|
return &DummyDriver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DummyDriver) GetInstances(target string) ([]incus.Instance, error) {
|
||||||
|
instances := []incus.Instance{
|
||||||
|
{
|
||||||
|
Name: "test0",
|
||||||
|
Config: map[string]string{
|
||||||
|
"sync": "true",
|
||||||
|
"sync-schedule": "* * * * *",
|
||||||
|
"sync-target-remote": "racker1",
|
||||||
|
"sync-target-pool": "pool0",
|
||||||
|
"sync-target-instance-suffix": "-cold",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
func (d *DummyDriver) Sync(sourceInstance string, targetInstance string, targetHost string, targetPool string) error {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (d *DummyDriver) Backup(instance string, tags []string) error {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
8
go.mod
Normal file
8
go.mod
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
module gitea.ceperka.net/rosti/incus-sentinel
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||||
|
)
|
12
go.sum
Normal file
12
go.sum
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
|
||||||
|
github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
27
http_notifier/main.go
Normal file
27
http_notifier/main.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package http_notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPNotifier struct{}
|
||||||
|
|
||||||
|
func NewNotifier() *HTTPNotifier {
|
||||||
|
return &HTTPNotifier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *HTTPNotifier) Notify(url string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to call URL %s: %s", url, err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if !slices.Contains([]int{http.StatusOK, http.StatusCreated, http.StatusNoContent}, resp.StatusCode) {
|
||||||
|
return fmt.Errorf("Unexpected status code %d from URL %s", resp.StatusCode, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
100
incus/main.go
Normal file
100
incus/main.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
package incus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IncusDriver struct{}
|
||||||
|
|
||||||
|
func NewIncusDriver() *IncusDriver {
|
||||||
|
return &IncusDriver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *IncusDriver) GetInstances(target string) ([]Instance, error) {
|
||||||
|
// Command: incus list -f json
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if target == "" {
|
||||||
|
cmd = exec.Command("incus", "list", "--format", "json")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("incus", "list", target+":", "--format", "json")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to execute incus list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var instances []Instance
|
||||||
|
err = json.Unmarshal(output, &instances)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *IncusDriver) Sync(sourceInstance string, targetInstance string, targetHost string, targetPool string) error {
|
||||||
|
// incus copy edge0 racker1:edge0-cold -s pool0 --mode push -p default -p net_edge0 --stateless --refresh
|
||||||
|
// incus copy edge0 racker1:edge0-cold -s pool0 --mode push -p default -p net_edge0 --stateless
|
||||||
|
|
||||||
|
instances, err := d.GetInstances(targetHost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if len(instances) == 0 {
|
||||||
|
cmd = exec.Command("incus", "copy", sourceInstance, targetHost+":"+targetInstance, "-s", targetPool, "--mode", "push", "--stateless")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("incus", "copy", sourceInstance, targetHost+":"+targetInstance, "-s", targetPool, "--mode", "push", "--stateless", "--refresh")
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute incus copy: %w (%s)", err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *IncusDriver) Backup(instance string, tags []string) error {
|
||||||
|
// incus export ups - -q --compression=zstd --instance-only --optimized-storage | restic backup --stdin --stdin-filename ups.btrfs.zstd --tag instance
|
||||||
|
|
||||||
|
// Create the incus export command
|
||||||
|
incusCmd := exec.Command("incus", "export", instance, "-", "-q", "--compression=zstd", "--instance-only", "--optimized-storage")
|
||||||
|
|
||||||
|
// Create the restic backup command
|
||||||
|
resticCmd := exec.Command("restic", "backup", "--host", instance, "--stdin", "--stdin-filename", fmt.Sprintf("%s.btrfs.zstd", instance), "--tag", strings.Join(tags, ","))
|
||||||
|
|
||||||
|
// Connect the output of incusCmd to the input of resticCmd
|
||||||
|
pipe, err := incusCmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create pipe: %w", err)
|
||||||
|
}
|
||||||
|
resticCmd.Stdin = pipe
|
||||||
|
|
||||||
|
// Start the incus export command
|
||||||
|
if err := incusCmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start incus export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the restic backup command
|
||||||
|
if err := resticCmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start restic backup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the incus export command to finish
|
||||||
|
if err := incusCmd.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("incus export command failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the restic backup command to finish
|
||||||
|
if err := resticCmd.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("restic backup command failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
87
incus/types.go
Normal file
87
incus/types.go
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
package incus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceSentinel struct {
|
||||||
|
Backup bool
|
||||||
|
Sync bool
|
||||||
|
BackupSchedule string
|
||||||
|
BackupNotifyURL string
|
||||||
|
SyncNotifyURL string
|
||||||
|
SyncSchedule string
|
||||||
|
SyncTargetRemote string
|
||||||
|
SyncTargetPool string
|
||||||
|
SyncTargetInstanceSuffix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Instance struct {
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
Devices map[string]interface{} `json:"devices"`
|
||||||
|
Ephemeral bool `json:"ephemeral"`
|
||||||
|
Profiles []string `json:"profiles"`
|
||||||
|
Stateful bool `json:"stateful"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Project string `json:"project"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Instance) Sentinel() InstanceSentinel {
|
||||||
|
s := InstanceSentinel{
|
||||||
|
Backup: false,
|
||||||
|
Sync: false,
|
||||||
|
BackupNotifyURL: "",
|
||||||
|
SyncNotifyURL: "",
|
||||||
|
BackupSchedule: "0 6 * * *",
|
||||||
|
SyncSchedule: "0 6 * * *",
|
||||||
|
SyncTargetRemote: "",
|
||||||
|
SyncTargetPool: "pool0",
|
||||||
|
SyncTargetInstanceSuffix: "-cold",
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.backup"]; ok {
|
||||||
|
s.Backup = val == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync"]; ok {
|
||||||
|
s.Sync = val == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.backup-notify-url"]; ok {
|
||||||
|
s.BackupNotifyURL = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync-notify-url"]; ok {
|
||||||
|
s.SyncNotifyURL = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.backup-schedule"]; ok {
|
||||||
|
s.BackupSchedule = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync-schedule"]; ok {
|
||||||
|
s.SyncSchedule = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync-target-remote"]; ok {
|
||||||
|
s.SyncTargetRemote = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync-target-pool"]; ok {
|
||||||
|
s.SyncTargetPool = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := i.Config["user.sync-target-instance-suffix"]; ok {
|
||||||
|
s.SyncTargetInstanceSuffix = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
117
main.go
Normal file
117
main.go
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.ceperka.net/rosti/incus-sentinel/http_notifier"
|
||||||
|
"gitea.ceperka.net/rosti/incus-sentinel/incus"
|
||||||
|
"gitea.ceperka.net/rosti/incus-sentinel/scheduler"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Name: "incus-sentinel",
|
||||||
|
Usage: "Keeps an eye on sync and backups of Incus instances",
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Usage: "List all instances with their sync and backup settings",
|
||||||
|
Flags: []cli.Flag{},
|
||||||
|
Action: func(c context.Context, cmd *cli.Command) error {
|
||||||
|
i := incus.NewIncusDriver()
|
||||||
|
insts, err := i.GetInstances("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inst := range insts {
|
||||||
|
s := inst.Sentinel()
|
||||||
|
fmt.Printf("%s\n", inst.Name)
|
||||||
|
fmt.Printf(" Backup: %t (%s)\n", s.Backup, s.BackupSchedule)
|
||||||
|
fmt.Printf(" Sync: %t (%s)\n", s.Sync, s.SyncSchedule)
|
||||||
|
if s.Sync {
|
||||||
|
fmt.Printf(" Sync Target: %s (pool: %s, suffix: %s)\n", s.SyncTargetRemote, s.SyncTargetPool, s.SyncTargetInstanceSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sync",
|
||||||
|
Usage: "Syncs all instances where sync is enabled",
|
||||||
|
Flags: []cli.Flag{},
|
||||||
|
Action: func(c context.Context, cmd *cli.Command) error {
|
||||||
|
i := incus.NewIncusDriver()
|
||||||
|
insts, err := i.GetInstances("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inst := range insts {
|
||||||
|
s := inst.Sentinel()
|
||||||
|
if s.Sync {
|
||||||
|
fmt.Println(".. syncing", inst.Name)
|
||||||
|
err := i.Sync(inst.Name, fmt.Sprintf("%s%s", inst.Name, s.SyncTargetInstanceSuffix), s.SyncTargetRemote, s.SyncTargetPool)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "backup",
|
||||||
|
Usage: "Backs up all instances where backup is enabled",
|
||||||
|
Flags: []cli.Flag{},
|
||||||
|
Action: func(c context.Context, cmd *cli.Command) error {
|
||||||
|
i := incus.NewIncusDriver()
|
||||||
|
insts, err := i.GetInstances("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inst := range insts {
|
||||||
|
s := inst.Sentinel()
|
||||||
|
if s.Backup {
|
||||||
|
fmt.Println(".. backing up", inst.Name)
|
||||||
|
err := i.Backup(inst.Name, []string{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "run",
|
||||||
|
Usage: "Runs the sentinel that syncs and backs up instances based on their configuration",
|
||||||
|
Flags: []cli.Flag{},
|
||||||
|
Action: func(c context.Context, cmd *cli.Command) error {
|
||||||
|
i := incus.NewIncusDriver()
|
||||||
|
n := http_notifier.NewNotifier()
|
||||||
|
|
||||||
|
s := scheduler.NewScheduler(i, n)
|
||||||
|
err := s.Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Run(context.Background(), os.Args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
249
scheduler/main.go
Normal file
249
scheduler/main.go
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.ceperka.net/rosti/incus-sentinel/incus"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const queueLength = 100
|
||||||
|
const planReasonBackup = "backup"
|
||||||
|
const planReasonSync = "sync"
|
||||||
|
|
||||||
|
type schedulerPlan struct {
|
||||||
|
Instance incus.Instance
|
||||||
|
Reason string // backup or sync
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler is the main struct that handles the scheduling of backups and syncs.
|
||||||
|
// It keeps internal queue of instances to be processed and it runs cron-like
|
||||||
|
// schedules for each instance.
|
||||||
|
type Scheduler struct {
|
||||||
|
driver Driver
|
||||||
|
notifier Notifier
|
||||||
|
|
||||||
|
planner chan schedulerPlan // Receives instance to be backed up and/or synced
|
||||||
|
queueDumpRequest chan bool // Request to dump the queue
|
||||||
|
queueDumpResponse chan []schedulerPlan // Response with the queue
|
||||||
|
exit chan bool // Exit signal
|
||||||
|
|
||||||
|
scheduledFootprint string // Footprint of the scheduler, used to identify if the scheduler needs to be changed
|
||||||
|
cron *cron.Cron
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScheduler(d Driver, n Notifier) *Scheduler {
|
||||||
|
cron.New(cron.WithSeconds())
|
||||||
|
|
||||||
|
s := &Scheduler{
|
||||||
|
driver: d,
|
||||||
|
notifier: n,
|
||||||
|
planner: make(chan schedulerPlan, 10),
|
||||||
|
queueDumpRequest: make(chan bool, 1),
|
||||||
|
queueDumpResponse: make(chan []schedulerPlan, 1),
|
||||||
|
exit: make(chan bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.runMainLoop()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) do(plan schedulerPlan, done chan schedulerPlan) {
|
||||||
|
defer func() {
|
||||||
|
done <- plan
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Do the actual work
|
||||||
|
sen := plan.Instance.Sentinel()
|
||||||
|
var err error
|
||||||
|
if plan.Reason == planReasonBackup {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
err = s.driver.Backup(plan.Instance.Name, []string{"sentinel"})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to backup %s: %s", plan.Instance.Name, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Backup of %s took %s", plan.Instance.Name, time.Since(start).String())
|
||||||
|
|
||||||
|
if sen.BackupNotifyURL != "" && s.notifier != nil {
|
||||||
|
err = s.notifier.Notify(sen.BackupNotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to notify %s: %s", sen.BackupNotifyURL, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.Reason == planReasonSync {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
err = s.driver.Sync(plan.Instance.Name, plan.Instance.Name+sen.SyncTargetInstanceSuffix, sen.SyncTargetRemote, sen.SyncTargetPool)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to sync %s: %s", plan.Instance.Name, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Sync of %s took %s", plan.Instance.Name, time.Since(start).String())
|
||||||
|
|
||||||
|
if sen.SyncNotifyURL != "" && s.notifier != nil {
|
||||||
|
err = s.notifier.Notify(sen.SyncNotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to notify %s: %s", sen.SyncNotifyURL, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop that does the heavy-lifting
|
||||||
|
func (s *Scheduler) runMainLoop() error {
|
||||||
|
inProgress := false
|
||||||
|
done := make(chan schedulerPlan)
|
||||||
|
instancesToProcess := []schedulerPlan{}
|
||||||
|
|
||||||
|
queue := make(chan schedulerPlan, queueLength)
|
||||||
|
queuedInstances := make(map[string]schedulerPlan)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
// New instance to plan
|
||||||
|
case newPlan := <-s.planner:
|
||||||
|
if len(queue) >= queueLength {
|
||||||
|
log.Printf("Queue full (%d)), dropping plan for %s of %s", len(queue), newPlan.Reason, newPlan.Instance.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if instance is already planned
|
||||||
|
_, found := queuedInstances[newPlan.Reason+":"+newPlan.Instance.Name]
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
queue <- newPlan
|
||||||
|
queuedInstances[newPlan.Reason+":"+newPlan.Instance.Name] = newPlan
|
||||||
|
}
|
||||||
|
// Instance is done, remove it from the tracker
|
||||||
|
case plan := <-done:
|
||||||
|
delete(queuedInstances, plan.Reason+":"+plan.Instance.Name)
|
||||||
|
inProgress = false
|
||||||
|
// Dump the queue
|
||||||
|
case <-s.queueDumpRequest:
|
||||||
|
response := []schedulerPlan{}
|
||||||
|
copy(response, instancesToProcess)
|
||||||
|
s.queueDumpResponse <- response
|
||||||
|
case <-s.exit:
|
||||||
|
if inProgress {
|
||||||
|
log.Println("Waiting for the current instance to finish ..")
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
// Check the queue and start processing
|
||||||
|
case <-ticker.C:
|
||||||
|
if !inProgress && len(queue) > 0 {
|
||||||
|
inProgress = true
|
||||||
|
go s.do(<-queue, done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Close() {
|
||||||
|
s.exit <- true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Run() error {
|
||||||
|
lastQueueDump := ""
|
||||||
|
|
||||||
|
log.Println("Starting scheduler ..")
|
||||||
|
for {
|
||||||
|
s.refresh()
|
||||||
|
|
||||||
|
// Print the list of instances in the queue
|
||||||
|
s.queueDumpRequest <- true
|
||||||
|
plans := <-s.queueDumpResponse
|
||||||
|
if len(plans) > 0 {
|
||||||
|
output := []string{}
|
||||||
|
for _, plan := range plans {
|
||||||
|
output = append(output, plan.Reason+":"+plan.Instance.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputString := strings.Join(output, ", ")
|
||||||
|
if lastQueueDump != outputString {
|
||||||
|
log.Println("Queue:", outputString)
|
||||||
|
lastQueueDump = outputString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) footprint(is []incus.Instance) string {
|
||||||
|
footprint := ""
|
||||||
|
for _, inst := range is {
|
||||||
|
sen := inst.Sentinel()
|
||||||
|
footprint += inst.Name + sen.BackupSchedule + sen.SyncSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
return footprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if the scheduler needs to be refreshed and does it if needed
|
||||||
|
func (s *Scheduler) refresh() error {
|
||||||
|
instances, err := s.driver.GetInstances("")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.setupInstanceSchedules(instances)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh cron like schedulers for all instances
|
||||||
|
func (s *Scheduler) setupInstanceSchedules(is []incus.Instance) error {
|
||||||
|
if s.scheduledFootprint == s.footprint(is) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Refreshing scheduler ..")
|
||||||
|
|
||||||
|
if s.cron != nil {
|
||||||
|
s.cron.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cron = cron.New()
|
||||||
|
|
||||||
|
for _, inst := range is {
|
||||||
|
sen := inst.Sentinel()
|
||||||
|
if sen.Backup {
|
||||||
|
log.Println(".. adding backup schedule for", inst.Name, sen.BackupSchedule)
|
||||||
|
_, err := s.cron.AddFunc(sen.BackupSchedule, func() {
|
||||||
|
s.planner <- schedulerPlan{Instance: inst, Reason: planReasonBackup}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sen.Sync {
|
||||||
|
log.Println(".. adding sync schedule for", inst.Name, sen.SyncSchedule)
|
||||||
|
_, err := s.cron.AddFunc(sen.SyncSchedule, func() {
|
||||||
|
s.planner <- schedulerPlan{Instance: inst, Reason: planReasonSync}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.scheduledFootprint = s.footprint(is)
|
||||||
|
s.cron.Start()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
19
scheduler/types.go
Normal file
19
scheduler/types.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import "gitea.ceperka.net/rosti/incus-sentinel/incus"
|
||||||
|
|
||||||
|
type Driver interface {
|
||||||
|
// GetInstances retrieves a list of instances from the target
|
||||||
|
GetInstances(target string) ([]incus.Instance, error)
|
||||||
|
|
||||||
|
// Sync synchronizes instances between source and target
|
||||||
|
Sync(sourceInstance string, targetInstance string, targetHost string, targetPool string) error
|
||||||
|
|
||||||
|
// Backup creates a backup of the specified instance with given tags
|
||||||
|
Backup(instance string, tags []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
// Notify sends a notification to the specified URL
|
||||||
|
Notify(url string) error
|
||||||
|
}
|
Loading…
Reference in a new issue