refactor: extract ProxyProvider interface with None and NPM implementations
Replace direct npm.Client usage throughout the codebase with the proxy.Provider interface, enabling pluggable proxy backends. The deployer, API layer, and proxy manager now use provider-agnostic route management (ConfigureRoute/DeleteRoute) instead of NPM-specific API calls. Adds ProxyRouteID (string) to Instance model and ProxyProvider setting to Settings, with SQLite migrations for backward compatibility.
This commit is contained in:
@@ -24,11 +24,11 @@ func (d *Deployer) blueGreenDeploy(
|
||||
settings store.Settings,
|
||||
deployID string,
|
||||
imageTag string,
|
||||
) (string, int, string, error) {
|
||||
) (string, string, string, error) {
|
||||
// Find existing running instance for this stage (the "blue" instance).
|
||||
existingInstances, err := d.store.GetInstancesByStageID(stage.ID)
|
||||
if err != nil {
|
||||
return "", 0, "", fmt.Errorf("get existing instances: %w", err)
|
||||
return "", "", "", fmt.Errorf("get existing instances: %w", err)
|
||||
}
|
||||
|
||||
var blueInstance *store.Instance
|
||||
@@ -49,18 +49,18 @@ func (d *Deployer) blueGreenDeploy(
|
||||
|
||||
authConfig, err := d.buildRegistryAuth(project)
|
||||
if err != nil {
|
||||
return "", 0, "", fmt.Errorf("build registry auth: %w", err)
|
||||
return "", "", "", fmt.Errorf("build registry auth: %w", err)
|
||||
}
|
||||
|
||||
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
|
||||
return "", 0, "", fmt.Errorf("pull image: %w", err)
|
||||
return "", "", "", fmt.Errorf("pull image: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, "Image pulled successfully", "info")
|
||||
|
||||
// Step 2: Ensure network.
|
||||
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
|
||||
if err != nil {
|
||||
return "", 0, "", fmt.Errorf("ensure network: %w", err)
|
||||
return "", "", "", fmt.Errorf("ensure network: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Create and start green container.
|
||||
@@ -92,7 +92,7 @@ func (d *Deployer) blueGreenDeploy(
|
||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
|
||||
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
|
||||
if err != nil {
|
||||
return "", 0, instanceID, fmt.Errorf("create container: %w", err)
|
||||
return "", "", instanceID, fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
|
||||
// Create instance record.
|
||||
@@ -107,7 +107,7 @@ func (d *Deployer) blueGreenDeploy(
|
||||
Port: project.Port,
|
||||
})
|
||||
if err != nil {
|
||||
return containerID, 0, instanceID, fmt.Errorf("create instance record: %w", err)
|
||||
return containerID, "", instanceID, fmt.Errorf("create instance record: %w", err)
|
||||
}
|
||||
instanceID = inst.ID
|
||||
|
||||
@@ -117,7 +117,7 @@ func (d *Deployer) blueGreenDeploy(
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info")
|
||||
if err := d.docker.StartContainer(ctx, containerID); err != nil {
|
||||
return containerID, 0, instanceID, fmt.Errorf("start container: %w", err)
|
||||
return containerID, "", instanceID, fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
|
||||
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
|
||||
@@ -136,25 +136,25 @@ func (d *Deployer) blueGreenDeploy(
|
||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info")
|
||||
|
||||
if err := d.health.Check(ctx, healthURL); err != nil {
|
||||
return containerID, 0, instanceID, fmt.Errorf("health check green: %w", err)
|
||||
return containerID, "", instanceID, fmt.Errorf("health check green: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, "Blue-green: green health check passed", "info")
|
||||
}
|
||||
|
||||
// Step 5: Swap NPM proxy to green.
|
||||
var npmProxyID int
|
||||
// Step 5: Swap proxy to green.
|
||||
var proxyRouteID string
|
||||
if stage.EnableProxy {
|
||||
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
|
||||
slog.Warn("update deploy status", "error", err)
|
||||
}
|
||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
||||
|
||||
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
||||
if err != nil {
|
||||
return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||
}
|
||||
|
||||
inst.NpmProxyID = npmProxyID
|
||||
inst.ProxyRouteID = proxyRouteID
|
||||
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
|
||||
|
||||
// Create/update DNS record for the green instance.
|
||||
@@ -180,5 +180,5 @@ func (d *Deployer) blueGreenDeploy(
|
||||
}
|
||||
}
|
||||
|
||||
return containerID, npmProxyID, instanceID, nil
|
||||
return containerID, proxyRouteID, instanceID, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/health"
|
||||
"github.com/alexei/docker-watcher/internal/notify"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// It implements both webhook.DeployTriggerer and registry.DeployTriggerer.
|
||||
type Deployer struct {
|
||||
docker *docker.Client
|
||||
npm *npm.Client
|
||||
proxy proxy.Provider
|
||||
store *store.Store
|
||||
health *health.Checker
|
||||
notifier *notify.Notifier
|
||||
@@ -49,7 +49,7 @@ type EventPublisher interface {
|
||||
// New creates a new Deployer with all required dependencies.
|
||||
func New(
|
||||
dockerClient *docker.Client,
|
||||
npmClient *npm.Client,
|
||||
proxyProvider proxy.Provider,
|
||||
st *store.Store,
|
||||
checker *health.Checker,
|
||||
notifier *notify.Notifier,
|
||||
@@ -58,7 +58,7 @@ func New(
|
||||
) *Deployer {
|
||||
return &Deployer{
|
||||
docker: dockerClient,
|
||||
npm: npmClient,
|
||||
proxy: proxyProvider,
|
||||
store: st,
|
||||
health: checker,
|
||||
notifier: notifier,
|
||||
@@ -161,20 +161,20 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
|
||||
}
|
||||
|
||||
var containerID string
|
||||
var npmProxyID int
|
||||
var proxyRouteID string
|
||||
var instanceID string
|
||||
var deployErr error
|
||||
|
||||
if stage.MaxInstances == 1 {
|
||||
containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag)
|
||||
containerID, proxyRouteID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag)
|
||||
} else {
|
||||
containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag)
|
||||
containerID, proxyRouteID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag)
|
||||
}
|
||||
|
||||
if deployErr != nil {
|
||||
d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
|
||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error())
|
||||
d.rollback(ctx, deployID, containerID, npmProxyID, instanceID)
|
||||
d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID)
|
||||
|
||||
d.notifier.Send(settings.NotificationURL, notify.Event{
|
||||
Type: "deploy_failure",
|
||||
@@ -250,7 +250,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
|
||||
}
|
||||
|
||||
// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state.
|
||||
// It returns (containerID, npmProxyID, instanceID, error).
|
||||
// It returns (containerID, proxyRouteID, instanceID, error).
|
||||
func (d *Deployer) executeDeploy(
|
||||
ctx context.Context,
|
||||
project store.Project,
|
||||
@@ -258,9 +258,9 @@ func (d *Deployer) executeDeploy(
|
||||
settings store.Settings,
|
||||
deployID string,
|
||||
imageTag string,
|
||||
) (string, int, string, error) {
|
||||
) (string, string, string, error) {
|
||||
var containerID string
|
||||
var npmProxyID int
|
||||
var proxyRouteID string
|
||||
var instanceID string
|
||||
|
||||
// Step 1: Pull image.
|
||||
@@ -272,18 +272,18 @@ func (d *Deployer) executeDeploy(
|
||||
|
||||
authConfig, err := d.buildRegistryAuth(project)
|
||||
if err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("build registry auth: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("build registry auth: %w", err)
|
||||
}
|
||||
|
||||
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("pull image: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("pull image: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, "Image pulled successfully", "info")
|
||||
|
||||
// Step 2: Ensure network exists.
|
||||
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
|
||||
if err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("ensure network: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info")
|
||||
|
||||
@@ -318,7 +318,7 @@ func (d *Deployer) executeDeploy(
|
||||
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
|
||||
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
|
||||
if err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("create container: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info")
|
||||
|
||||
@@ -334,7 +334,7 @@ func (d *Deployer) executeDeploy(
|
||||
Port: project.Port,
|
||||
})
|
||||
if err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("create instance record: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("create instance record: %w", err)
|
||||
}
|
||||
instanceID = inst.ID
|
||||
|
||||
@@ -345,7 +345,7 @@ func (d *Deployer) executeDeploy(
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info")
|
||||
if err := d.docker.StartContainer(ctx, containerID); err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("start container: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
|
||||
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
|
||||
@@ -364,13 +364,13 @@ func (d *Deployer) executeDeploy(
|
||||
}
|
||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
||||
|
||||
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
||||
if err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||
}
|
||||
|
||||
// Update instance with NPM proxy ID.
|
||||
inst.NpmProxyID = npmProxyID
|
||||
// Update instance with proxy route ID.
|
||||
inst.ProxyRouteID = proxyRouteID
|
||||
inst.Subdomain = subdomain
|
||||
if err := d.store.UpdateInstance(inst); err != nil {
|
||||
slog.Warn("update instance with proxy ID", "error", err)
|
||||
@@ -398,19 +398,19 @@ func (d *Deployer) executeDeploy(
|
||||
d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info")
|
||||
|
||||
if err := d.health.Check(ctx, healthURL); err != nil {
|
||||
return containerID, npmProxyID, instanceID, fmt.Errorf("health check: %w", err)
|
||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("health check: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, "Health check passed", "info")
|
||||
} else {
|
||||
d.logDeploy(deployID, "No health check configured, skipping", "info")
|
||||
}
|
||||
|
||||
return containerID, npmProxyID, instanceID, nil
|
||||
return containerID, proxyRouteID, instanceID, nil
|
||||
}
|
||||
|
||||
// configureProxy creates or updates an NPM proxy host for the deployed container.
|
||||
// It authenticates to NPM using credentials from settings, then creates the proxy.
|
||||
// Returns the NPM proxy host ID.
|
||||
// configureProxy creates or updates a proxy route for the deployed container.
|
||||
// Uses the configured proxy.Provider (NPM, Traefik, or None).
|
||||
// Returns the proxy route ID string.
|
||||
func (d *Deployer) configureProxy(
|
||||
ctx context.Context,
|
||||
deployID string,
|
||||
@@ -418,64 +418,21 @@ func (d *Deployer) configureProxy(
|
||||
containerName string,
|
||||
containerPort int,
|
||||
subdomain string,
|
||||
) (int, error) {
|
||||
// Authenticate to NPM.
|
||||
npmPassword, err := d.decryptNpmPassword(settings.NpmPassword)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decrypt npm password: %w", err)
|
||||
}
|
||||
|
||||
if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil {
|
||||
return 0, fmt.Errorf("authenticate to npm: %w", err)
|
||||
}
|
||||
|
||||
) (string, error) {
|
||||
fqdn := subdomain + "." + settings.Domain
|
||||
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy: %s -> %s:%d", fqdn, containerName, containerPort), "info")
|
||||
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, containerName, containerPort), "info")
|
||||
|
||||
// Check if a proxy host already exists for this domain.
|
||||
existing, found, err := d.npm.FindProxyHostByDomain(ctx, fqdn)
|
||||
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, containerName, containerPort, proxy.RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find existing proxy host: %w", err)
|
||||
return "", fmt.Errorf("configure proxy route: %w", err)
|
||||
}
|
||||
|
||||
proxyConfig := npm.ProxyHostConfig{
|
||||
DomainNames: []string{fqdn},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: containerName,
|
||||
ForwardPort: containerPort,
|
||||
BlockExploits: true,
|
||||
AllowWebsocket: true,
|
||||
HTTP2Support: true,
|
||||
Meta: npm.Meta{},
|
||||
Locations: []any{},
|
||||
if routeID != "" {
|
||||
d.logDeploy(deployID, fmt.Sprintf("Proxy route configured (ID: %s)", routeID), "info")
|
||||
}
|
||||
|
||||
// Apply SSL certificate if configured in settings.
|
||||
if settings.SSLCertificateID > 0 {
|
||||
proxyConfig.CertificateID = settings.SSLCertificateID
|
||||
proxyConfig.SSLForced = true
|
||||
proxyConfig.HSTSEnabled = true
|
||||
proxyConfig.HTTP2Support = true
|
||||
d.logDeploy(deployID, fmt.Sprintf("Using SSL certificate ID %d", settings.SSLCertificateID), "info")
|
||||
}
|
||||
|
||||
if found {
|
||||
d.logDeploy(deployID, fmt.Sprintf("Updating existing proxy host %d for %s", existing.ID, fqdn), "info")
|
||||
host, err := d.npm.UpdateProxyHost(ctx, existing.ID, proxyConfig)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("update proxy host: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, "Proxy host updated", "info")
|
||||
return host.ID, nil
|
||||
}
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Creating new proxy host for %s", fqdn), "info")
|
||||
host, err := d.npm.CreateProxyHost(ctx, proxyConfig)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create proxy host: %w", err)
|
||||
}
|
||||
d.logDeploy(deployID, fmt.Sprintf("Proxy host created (ID: %d)", host.ID), "info")
|
||||
return host.ID, nil
|
||||
return routeID, nil
|
||||
}
|
||||
|
||||
// enforceMaxInstances removes the oldest instances when the stage has reached its limit.
|
||||
@@ -538,15 +495,10 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
|
||||
}
|
||||
}
|
||||
|
||||
// Delete NPM proxy host.
|
||||
if inst.NpmProxyID > 0 {
|
||||
npmPassword, err := d.decryptNpmPassword(settings.NpmPassword)
|
||||
if err != nil {
|
||||
slog.Warn("decrypt npm password for proxy cleanup", "error", err)
|
||||
} else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil {
|
||||
slog.Warn("authenticate npm for proxy cleanup", "error", authErr)
|
||||
} else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
|
||||
slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
|
||||
// Delete proxy route.
|
||||
if inst.ProxyRouteID != "" {
|
||||
if err := d.proxy.DeleteRoute(ctx, inst.ProxyRouteID); err != nil {
|
||||
slog.Warn("delete proxy route", "route_id", inst.ProxyRouteID, "error", err)
|
||||
}
|
||||
|
||||
// Remove DNS record for this instance.
|
||||
@@ -591,15 +543,6 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// decryptNpmPassword decrypts the NPM password from settings.
|
||||
// Returns empty string if the encrypted password is empty.
|
||||
func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error) {
|
||||
if encryptedPassword == "" {
|
||||
return "", nil
|
||||
}
|
||||
return crypto.Decrypt(d.encKey, encryptedPassword)
|
||||
}
|
||||
|
||||
// mergeEnvVars builds the final environment variable list for a container:
|
||||
// 1. Parse project-level env JSON
|
||||
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
)
|
||||
|
||||
// rollback cleans up a failed deployment by removing the container,
|
||||
// deleting the NPM proxy host, and updating the instance status.
|
||||
// deleting the proxy route, and updating the instance status.
|
||||
// Errors during rollback are logged but do not prevent other cleanup steps.
|
||||
func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, npmProxyID int, instanceID string) {
|
||||
func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, proxyRouteID string, instanceID string) {
|
||||
d.logDeploy(deployID, "Rolling back failed deployment", "warn")
|
||||
|
||||
// Remove the container if it was created.
|
||||
@@ -22,23 +22,13 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the NPM proxy host if it was created.
|
||||
if npmProxyID > 0 {
|
||||
settings, err := d.store.GetSettings()
|
||||
if err != nil {
|
||||
slog.Warn("rollback: get settings for npm auth", "error", err)
|
||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error")
|
||||
} else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil {
|
||||
slog.Warn("rollback: decrypt npm password", "error", err)
|
||||
d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error")
|
||||
} else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil {
|
||||
slog.Warn("rollback: authenticate npm", "error", err)
|
||||
d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error")
|
||||
} else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil {
|
||||
slog.Warn("rollback: delete proxy host", "proxy_id", npmProxyID, "error", err)
|
||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error")
|
||||
// Delete the proxy route if it was created.
|
||||
if proxyRouteID != "" {
|
||||
if err := d.proxy.DeleteRoute(ctx, proxyRouteID); err != nil {
|
||||
slog.Warn("rollback: delete proxy route", "route_id", proxyRouteID, "error", err)
|
||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy route: %v", err), "error")
|
||||
} else {
|
||||
d.logDeploy(deployID, "Rollback: proxy host deleted", "info")
|
||||
d.logDeploy(deployID, "Rollback: proxy route deleted", "info")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user