Compare commits
5 Commits
7ca94b4048
...
1f2baef062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2baef062 | ||
|
|
1de6a31e25 | ||
|
|
9a49ac96c4 | ||
|
|
e7bb4b1149 | ||
|
|
729505a216 |
@ -1,6 +1,6 @@
|
|||||||
# Build a small image
|
# Build a small image
|
||||||
FROM --platform=linux/arm64 alpine:3
|
FROM --platform=linux/arm64 alpine:3
|
||||||
RUN apk add --no-cache su-exec restic postgresql-client
|
RUN apk add --no-cache su-exec restic
|
||||||
COPY ./bin/formolcli /usr/local/bin
|
COPY ./bin/formolcli /usr/local/bin
|
||||||
|
|
||||||
# Command to run
|
# Command to run
|
||||||
|
|||||||
18
Makefile
18
Makefile
@ -1,12 +1,18 @@
|
|||||||
GOARCH ?= amd64
|
GOARCH ?= amd64
|
||||||
GOOS ?= linux
|
GOOS ?= linux
|
||||||
IMG ?= docker.io/desmo999r/formolcli:latest
|
VERSION ?= latest
|
||||||
|
IMG ?= docker.io/desmo999r/formolcli:$(VERSION)
|
||||||
|
MANIFEST = formol-multiarch
|
||||||
BINDIR = ./bin
|
BINDIR = ./bin
|
||||||
|
|
||||||
.PHONY: formolcli
|
.PHONY: formolcli
|
||||||
formolcli: fmt vet
|
formolcli: fmt vet
|
||||||
GO111MODULE=on CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(BINDIR)/formolcli main.go
|
GO111MODULE=on CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(BINDIR)/formolcli main.go
|
||||||
|
|
||||||
|
#.PHONY: formolcli-arm64
|
||||||
|
#formolcli-arm64: GOARCH = arm64
|
||||||
|
#formolcli-arm64: formolcli
|
||||||
|
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
@ -17,11 +23,15 @@ vet:
|
|||||||
|
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
docker-build: formolcli
|
docker-build: formolcli
|
||||||
buildah bud --tag $(IMG) Dockerfile.$(GOARCH)
|
buildah bud --tag $(IMG) --manifest $(MANIFEST) --arch $(GOARCH) Dockerfile.$(GOARCH)
|
||||||
|
|
||||||
|
.PHONY: docker-build-arm64
|
||||||
|
docker-build-arm64: GOARCH = arm64
|
||||||
|
docker-build-arm64: docker-build
|
||||||
|
|
||||||
.PHONY: docker-push
|
.PHONY: docker-push
|
||||||
docker-push: docker-build
|
docker-push:
|
||||||
buildah push $(IMG)
|
buildah manifest push --all --rm $(MANIFEST) "docker://$(IMG)"
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: formolcli docker-build
|
all: formolcli docker-build
|
||||||
|
|||||||
@ -21,6 +21,8 @@ type BackupSessionReconciler struct {
|
|||||||
func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
r.Log = log.FromContext(ctx)
|
r.Log = log.FromContext(ctx)
|
||||||
r.Context = ctx
|
r.Context = ctx
|
||||||
|
r.Namespace = req.NamespacedName.Namespace
|
||||||
|
r.Name = req.NamespacedName.Name
|
||||||
|
|
||||||
backupSession := formolv1alpha1.BackupSession{}
|
backupSession := formolv1alpha1.BackupSession{}
|
||||||
err := r.Get(ctx, req.NamespacedName, &backupSession)
|
err := r.Get(ctx, req.NamespacedName, &backupSession)
|
||||||
@ -47,7 +49,6 @@ func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
}
|
}
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
r.Namespace = backupConf.Namespace
|
|
||||||
|
|
||||||
// we don't want a copy because we will modify and update it.
|
// we don't want a copy because we will modify and update it.
|
||||||
var target formolv1alpha1.Target
|
var target formolv1alpha1.Target
|
||||||
@ -96,7 +97,7 @@ func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
newSessionState = formolv1alpha1.Waiting
|
newSessionState = formolv1alpha1.Waiting
|
||||||
switch target.BackupType {
|
switch target.BackupType {
|
||||||
case formolv1alpha1.JobKind:
|
case formolv1alpha1.JobKind:
|
||||||
if backupResult, err := r.backupJob(backupSession.Name, target); err != nil {
|
if backupResult, err := r.backupJob(target); err != nil {
|
||||||
r.Log.Error(err, "unable to run backup job", "target", targetName)
|
r.Log.Error(err, "unable to run backup job", "target", targetName)
|
||||||
newSessionState = formolv1alpha1.Failure
|
newSessionState = formolv1alpha1.Failure
|
||||||
} else {
|
} else {
|
||||||
@ -106,7 +107,7 @@ func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
}
|
}
|
||||||
case formolv1alpha1.OnlineKind:
|
case formolv1alpha1.OnlineKind:
|
||||||
backupPaths := strings.Split(os.Getenv(formolv1alpha1.BACKUP_PATHS), string(os.PathListSeparator))
|
backupPaths := strings.Split(os.Getenv(formolv1alpha1.BACKUP_PATHS), string(os.PathListSeparator))
|
||||||
if backupResult, result := r.backupPaths(backupSession.Name, backupPaths); result != nil {
|
if backupResult, result := r.backupPaths(backupPaths); result != nil {
|
||||||
r.Log.Error(result, "unable to backup paths", "target name", targetName, "paths", backupPaths)
|
r.Log.Error(result, "unable to backup paths", "target name", targetName, "paths", backupPaths)
|
||||||
newSessionState = formolv1alpha1.Failure
|
newSessionState = formolv1alpha1.Failure
|
||||||
} else {
|
} else {
|
||||||
@ -126,8 +127,6 @@ func (r *BackupSessionReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
|||||||
default:
|
default:
|
||||||
r.Log.Error(err, "unable to do snapshot backup")
|
r.Log.Error(err, "unable to do snapshot backup")
|
||||||
// TODO: cleanup existing snapshots
|
// TODO: cleanup existing snapshots
|
||||||
r.deleteVolumeSnapshots(target)
|
|
||||||
newSessionState = formolv1alpha1.Failure
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
"strings"
|
||||||
|
|
||||||
const (
|
|
||||||
SNAPSHOT_PREFIX = "formol-"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type BackupResult struct {
|
type BackupResult struct {
|
||||||
@ -23,13 +20,13 @@ type BackupResult struct {
|
|||||||
Duration float64
|
Duration float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) backupPaths(tag string, paths []string) (result BackupResult, err error) {
|
func (r *BackupSessionReconciler) backupPaths(paths []string) (result BackupResult, err error) {
|
||||||
if err = r.CheckRepo(); err != nil {
|
if err = r.CheckRepo(); err != nil {
|
||||||
r.Log.Error(err, "unable to setup repo", "repo", os.Getenv(formolv1alpha1.RESTIC_REPOSITORY))
|
r.Log.Error(err, "unable to setup repo", "repo", os.Getenv(formolv1alpha1.RESTIC_REPOSITORY))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Log.V(0).Info("backing up paths", "paths", paths)
|
r.Log.V(0).Info("backing up paths", "paths", paths)
|
||||||
cmd := exec.Command(RESTIC_EXEC, append([]string{"backup", "--json", "--tag", tag}, paths...)...)
|
cmd := exec.Command(RESTIC_EXEC, append([]string{"backup", "--json", "--tag", r.Name}, paths...)...)
|
||||||
stdout, _ := cmd.StdoutPipe()
|
stdout, _ := cmd.StdoutPipe()
|
||||||
stderr, _ := cmd.StderrPipe()
|
stderr, _ := cmd.StderrPipe()
|
||||||
_ = cmd.Start()
|
_ = cmd.Start()
|
||||||
@ -55,7 +52,7 @@ func (r *BackupSessionReconciler) backupPaths(tag string, paths []string) (resul
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) backupJob(tag string, target formolv1alpha1.Target) (result BackupResult, err error) {
|
func (r *BackupSessionReconciler) backupJob(target formolv1alpha1.Target) (result BackupResult, err error) {
|
||||||
paths := []string{}
|
paths := []string{}
|
||||||
for _, container := range target.Containers {
|
for _, container := range target.Containers {
|
||||||
for _, job := range container.Job {
|
for _, job := range container.Job {
|
||||||
@ -75,7 +72,7 @@ func (r *BackupSessionReconciler) backupJob(tag string, target formolv1alpha1.Ta
|
|||||||
paths = append(paths, container.SharePath)
|
paths = append(paths, container.SharePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err = r.backupPaths(tag, paths)
|
result, err = r.backupPaths(paths)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,10 +95,9 @@ func (r *BackupSessionReconciler) backupSnapshot(target formolv1alpha1.Target) e
|
|||||||
// sidecar := formolv1alpha1.GetSidecar(backupConf, target)
|
// sidecar := formolv1alpha1.GetSidecar(backupConf, target)
|
||||||
_, vms := formolv1alpha1.GetVolumeMounts(container, targetContainer)
|
_, vms := formolv1alpha1.GetVolumeMounts(container, targetContainer)
|
||||||
if err := r.snapshotVolumes(vms, targetPodSpec); err != nil {
|
if err := r.snapshotVolumes(vms, targetPodSpec); err != nil {
|
||||||
switch err.(type) {
|
if IsNotReadyToUse(err) {
|
||||||
case *NotReadyToUseError:
|
|
||||||
r.Log.V(0).Info("Some volumes are still not ready to use")
|
r.Log.V(0).Info("Some volumes are still not ready to use")
|
||||||
default:
|
} else {
|
||||||
r.Log.Error(err, "cannot snapshot the volumes")
|
r.Log.Error(err, "cannot snapshot the volumes")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -119,6 +115,15 @@ func (e *NotReadyToUseError) Error() string {
|
|||||||
return "Snapshot is not ready to use"
|
return "Snapshot is not ready to use"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsNotReadyToUse(err error) bool {
|
||||||
|
switch err.(type) {
|
||||||
|
case *NotReadyToUseError:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) snapshotVolume(volume corev1.Volume) (*volumesnapshotv1.VolumeSnapshot, error) {
|
func (r *BackupSessionReconciler) snapshotVolume(volume corev1.Volume) (*volumesnapshotv1.VolumeSnapshot, error) {
|
||||||
r.Log.V(0).Info("Preparing snapshot", "volume", volume.Name)
|
r.Log.V(0).Info("Preparing snapshot", "volume", volume.Name)
|
||||||
if volume.VolumeSource.PersistentVolumeClaim != nil {
|
if volume.VolumeSource.PersistentVolumeClaim != nil {
|
||||||
@ -148,9 +153,11 @@ func (r *BackupSessionReconciler) snapshotVolume(volume corev1.Volume) (*volumes
|
|||||||
if volumeSnapshotClass.Driver == pv.Spec.PersistentVolumeSource.CSI.Driver {
|
if volumeSnapshotClass.Driver == pv.Spec.PersistentVolumeSource.CSI.Driver {
|
||||||
// Check if a snapshot exist
|
// Check if a snapshot exist
|
||||||
volumeSnapshot := volumesnapshotv1.VolumeSnapshot{}
|
volumeSnapshot := volumesnapshotv1.VolumeSnapshot{}
|
||||||
|
volumeSnapshotName := strings.Join([]string{"vs", r.Name, pv.Name}, "-")
|
||||||
|
|
||||||
if err := r.Get(r.Context, client.ObjectKey{
|
if err := r.Get(r.Context, client.ObjectKey{
|
||||||
Namespace: r.Namespace,
|
Namespace: r.Namespace,
|
||||||
Name: SNAPSHOT_PREFIX + pv.Name,
|
Name: volumeSnapshotName,
|
||||||
}, &volumeSnapshot); errors.IsNotFound(err) {
|
}, &volumeSnapshot); errors.IsNotFound(err) {
|
||||||
// No snapshot found. Create a new one.
|
// No snapshot found. Create a new one.
|
||||||
// We want to snapshot using this VolumeSnapshotClass
|
// We want to snapshot using this VolumeSnapshotClass
|
||||||
@ -158,7 +165,7 @@ func (r *BackupSessionReconciler) snapshotVolume(volume corev1.Volume) (*volumes
|
|||||||
volumeSnapshot = volumesnapshotv1.VolumeSnapshot{
|
volumeSnapshot = volumesnapshotv1.VolumeSnapshot{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Namespace: r.Namespace,
|
Namespace: r.Namespace,
|
||||||
Name: SNAPSHOT_PREFIX + pv.Name,
|
Name: volumeSnapshotName,
|
||||||
},
|
},
|
||||||
Spec: volumesnapshotv1.VolumeSnapshotSpec{
|
Spec: volumesnapshotv1.VolumeSnapshotSpec{
|
||||||
VolumeSnapshotClassName: &volumeSnapshotClass.Name,
|
VolumeSnapshotClassName: &volumeSnapshotClass.Name,
|
||||||
@ -193,35 +200,79 @@ func (r *BackupSessionReconciler) snapshotVolume(volume corev1.Volume) (*volumes
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) createVolumeFromSnapshot(vs *volumesnapshotv1.VolumeSnapshot) {
|
func (r *BackupSessionReconciler) createVolumeFromSnapshot(vs *volumesnapshotv1.VolumeSnapshot) (backupPVCName string, err error) {
|
||||||
|
backupPVCName = strings.Replace(vs.Name, "vs", "bak", 1)
|
||||||
|
backupPVC := corev1.PersistentVolumeClaim{}
|
||||||
|
if err = r.Get(r.Context, client.ObjectKey{
|
||||||
|
Namespace: r.Namespace,
|
||||||
|
Name: backupPVCName,
|
||||||
|
}, &backupPVC); errors.IsNotFound(err) {
|
||||||
|
// The Volume does not exist. Create it.
|
||||||
|
pv := corev1.PersistentVolume{}
|
||||||
|
pvName, _ := strings.CutPrefix(vs.Name, strings.Join([]string{"vs", r.Name}, "-"))
|
||||||
|
if err = r.Get(r.Context, client.ObjectKey{
|
||||||
|
Name: pvName,
|
||||||
|
}, &pv); err != nil {
|
||||||
|
r.Log.Error(err, "unable to find pv", "pv", pvName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backupPVC = corev1.PersistentVolumeClaim{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Namespace: r.Namespace,
|
||||||
|
Name: backupPVCName,
|
||||||
|
},
|
||||||
|
Spec: corev1.PersistentVolumeClaimSpec{
|
||||||
|
StorageClassName: &pv.Spec.StorageClassName,
|
||||||
|
//AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadOnlyMany},
|
||||||
|
AccessModes: pv.Spec.AccessModes,
|
||||||
|
DataSource: &corev1.TypedLocalObjectReference{
|
||||||
|
APIGroup: func() *string { s := "snapshot.storage.k8s.io"; return &s }(),
|
||||||
|
Kind: "VolumeSnapshot",
|
||||||
|
Name: vs.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err = r.Create(r.Context, &backupPVC); err != nil {
|
||||||
|
r.Log.Error(err, "unable to create backup PVC", "backupPVC", backupPVC)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
r.Log.Error(err, "something went very wrong here")
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) snapshotVolumes(vms []corev1.VolumeMount, podSpec *corev1.PodSpec) (err error) {
|
func (r *BackupSessionReconciler) snapshotVolumes(vms []corev1.VolumeMount, podSpec *corev1.PodSpec) (err error) {
|
||||||
// We snapshot/check all the volumes. If at least one of the snapshot is not ready to use. We reschedule.
|
// We snapshot/check all the volumes. If at least one of the snapshot is not ready to use. We reschedule.
|
||||||
for _, vm := range vms {
|
for _, vm := range vms {
|
||||||
for _, volume := range podSpec.Volumes {
|
for i, volume := range podSpec.Volumes {
|
||||||
if vm.Name == volume.Name {
|
if vm.Name == volume.Name {
|
||||||
var vs *volumesnapshotv1.VolumeSnapshot
|
var vs *volumesnapshotv1.VolumeSnapshot
|
||||||
vs, err = r.snapshotVolume(volume)
|
vs, err = r.snapshotVolume(volume)
|
||||||
|
if IsNotReadyToUse(err) {
|
||||||
|
defer func() {
|
||||||
|
err = &NotReadyToUseError{}
|
||||||
|
}()
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err.(type) {
|
return
|
||||||
case *NotReadyToUseError:
|
|
||||||
defer func() {
|
|
||||||
err = &NotReadyToUseError{}
|
|
||||||
}()
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if vs != nil {
|
if vs != nil {
|
||||||
r.createVolumeFromSnapshot(vs)
|
backupPVCName, err := r.createVolumeFromSnapshot(vs)
|
||||||
|
if err != nil {
|
||||||
|
r.Log.Error(err, "unable to create volume from snapshot", "vs", vs)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
podSpec.Volumes[i].VolumeSource.PersistentVolumeClaim = &corev1.PersistentVolumeClaimVolumeSource{
|
||||||
|
ClaimName: backupPVCName,
|
||||||
|
ReadOnly: true,
|
||||||
|
}
|
||||||
|
// The snapshot and the volume will be deleted by the Job when the backup is over
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BackupSessionReconciler) deleteVolumeSnapshots(target formolv1alpha1.Target) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -19,6 +19,8 @@ type RestoreSessionReconciler struct {
|
|||||||
func (r *RestoreSessionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
func (r *RestoreSessionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
r.Log = log.FromContext(ctx)
|
r.Log = log.FromContext(ctx)
|
||||||
r.Context = ctx
|
r.Context = ctx
|
||||||
|
r.Namespace = req.NamespacedName.Namespace
|
||||||
|
r.Name = req.NamespacedName.Name
|
||||||
|
|
||||||
restoreSession := formolv1alpha1.RestoreSession{}
|
restoreSession := formolv1alpha1.RestoreSession{}
|
||||||
err := r.Get(r.Context, req.NamespacedName, &restoreSession)
|
err := r.Get(r.Context, req.NamespacedName, &restoreSession)
|
||||||
@ -49,7 +51,6 @@ func (r *RestoreSessionReconciler) Reconcile(ctx context.Context, req ctrl.Reque
|
|||||||
}
|
}
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
r.Namespace = backupConf.Namespace
|
|
||||||
r.backupConf = backupConf
|
r.backupConf = backupConf
|
||||||
|
|
||||||
// we don't want a copy because we will modify and update it.
|
// we don't want a copy because we will modify and update it.
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type Session struct {
|
|||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
context.Context
|
context.Context
|
||||||
Namespace string
|
Namespace string
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
2
formol
2
formol
@ -1 +1 @@
|
|||||||
Subproject commit 61f45a79404e1f71d9f7661d295d6ac3cd07dd8c
|
Subproject commit 8975f77e5858ee167508ef0359c3b9d6cbaba6ee
|
||||||
@ -22,6 +22,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BACKUPSESSION_PREFIX = "bs"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
session controllers.Session
|
session controllers.Session
|
||||||
)
|
)
|
||||||
@ -128,7 +132,7 @@ func CreateBackupSession(ref corev1.ObjectReference) {
|
|||||||
|
|
||||||
backupSession := &formolv1alpha1.BackupSession{
|
backupSession := &formolv1alpha1.BackupSession{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: strings.Join([]string{"backupsession", ref.Name, strconv.FormatInt(time.Now().Unix(), 10)}, "-"),
|
Name: strings.Join([]string{BACKUPSESSION_PREFIX, ref.Name, strconv.FormatInt(time.Now().Unix(), 10)}, "-"),
|
||||||
Namespace: ref.Namespace,
|
Namespace: ref.Namespace,
|
||||||
},
|
},
|
||||||
Spec: formolv1alpha1.BackupSessionSpec{
|
Spec: formolv1alpha1.BackupSessionSpec{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user