Add dev/prod environments with TrueNAS Docker production deploy.
Split PostgreSQL into gallery_dev and gallery_prod, add Docker/Gitea deploy tooling, SMB image sync, pgAdmin split script, dev:web on Keenetic :5173, and operator docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
02d238b043
commit
2edf577faf
@@ -0,0 +1,14 @@
|
||||
# Copy to infra/docker/.env.prod on TrueNAS or build PC (do not commit).
|
||||
# Used by compose.prod.yaml and npm run db:migrate:prod / db:restore:prod.
|
||||
|
||||
DB_HOST=192.168.10.122
|
||||
DB_PORT=5432
|
||||
DB_USER=gallery
|
||||
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
|
||||
DB_NAME=gallery_prod
|
||||
|
||||
PORT=5173
|
||||
HOST=0.0.0.0
|
||||
PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro
|
||||
TRUST_PROXY=true
|
||||
IMAGE_DIR=/app/data/images
|
||||
@@ -0,0 +1,149 @@
|
||||
# Deploy Gallery on TrueNAS Scale
|
||||
|
||||
|
||||
|
||||
Production deployment for the **Express API + built Vite SPA** container. PostgreSQL stays on the host at `192.168.10.122:5432`. Public URL: **https://gallery.mysuperlab.netcraze.pro** (Keenetic → TrueNAS `:5173`, protocol to device **http**).
|
||||
|
||||
|
||||
|
||||
See also [Documentation/environments.md](../../Documentation/environments.md).
|
||||
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
|
||||
```text
|
||||
|
||||
Browser (HTTPS)
|
||||
|
||||
→ Keenetic (KeenDNS, SSL termination)
|
||||
|
||||
→ gallery-web container on TrueNAS (:5173, HTTP)
|
||||
|
||||
→ PostgreSQL (192.168.10.122:5432) → gallery_prod
|
||||
|
||||
→ /mnt/BasePool/Applications/Gallery/data/images (volume)
|
||||
|
||||
→ SMB share Gallery → \\192.168.10.122\Gallery (image sync from dev PC)
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
|
||||
| Item | Notes |
|
||||
|
||||
|------|-------|
|
||||
|
||||
| TrueNAS Scale 25.04+ | Apps → Custom App support |
|
||||
|
||||
| PostgreSQL | `gallery_prod` (dev: `gallery_dev` on same host) — split via [pgAdmin script](../../db/split-dev-prod-pgadmin.sql) |
|
||||
|
||||
| Gitea registry | `gitea.mysuperlab.netcraze.pro` — image **`danilka/gallery-web:latest`** pushed before deploy |
|
||||
|
||||
| SMB share | **`Gallery`** at `/mnt/BasePool/Applications/Gallery` for `images:sync-to-prod` |
|
||||
|
||||
| Keenetic | `gallery.mysuperlab.netcraze.pro` → `192.168.10.122:5173`, protocol **`http`**, Preserve Host ON |
|
||||
|
||||
|
||||
|
||||
If deploy fails with **`manifest unknown`**, the image is not in Gitea yet — complete [§1 Build and push](#1-build-and-push-image-dev-machine) first.
|
||||
|
||||
|
||||
|
||||
## 1. Build and push image (dev machine)
|
||||
|
||||
|
||||
|
||||
**Where:** Dev PC — **PowerShell as Administrator** (LAN push hosts entry), **Docker Desktop running**
|
||||
|
||||
|
||||
|
||||
```powershell
|
||||
|
||||
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
|
||||
|
||||
docker login gitea.mysuperlab.netcraze.pro
|
||||
|
||||
npm run docker:publish
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
Push only (already built): `npm run docker:publish:push-only`
|
||||
|
||||
|
||||
|
||||
Image: `gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest`
|
||||
|
||||
|
||||
|
||||
### Gitea registry tokens
|
||||
|
||||
|
||||
|
||||
| Machine | Token scope |
|
||||
|
||||
|---------|-------------|
|
||||
|
||||
| Build PC | `write:package` |
|
||||
|
||||
| TrueNAS pull | `read:package` |
|
||||
|
||||
|
||||
|
||||
Register on TrueNAS: **Apps → Configuration → Sign in to a Docker registry** — URL `https://gitea.mysuperlab.netcraze.pro`, username `danilka` (lowercase for registry).
|
||||
|
||||
|
||||
|
||||
### Registry token URL fix
|
||||
|
||||
|
||||
|
||||
If `docker push` fails with internal HTTP token URL, set Gitea `ROOT_URL` to `https://gitea.mysuperlab.netcraze.pro/` and restart Gitea. Full walkthrough: [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md).
|
||||
|
||||
|
||||
|
||||
### Offline fallback (no registry)
|
||||
|
||||
|
||||
|
||||
On **dev PC**: `.\infra\docker\save-for-truenas.ps1` → copy `gallery-web.tar` via SMB `Gallery` share.
|
||||
|
||||
|
||||
|
||||
On **TrueNAS shell**: `sudo bash truenas-load-image.sh /path/to/gallery-web.tar`
|
||||
|
||||
|
||||
|
||||
In Custom App YAML: `pull_policy: if_not_present`, then redeploy.
|
||||
|
||||
|
||||
|
||||
## 2. Prepare TrueNAS storage
|
||||
|
||||
|
||||
|
||||
**Where:** TrueNAS — **Shell**, as **root**
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits
|
||||
|
||||
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs
|
||||
|
||||
chown -R 1001:1001 /mnt/BasePool/Applications/Gallery
|
||||
|
||||
chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Production image for Gallery (Express API + built Vite SPA).
|
||||
# Build from repository root:
|
||||
# docker build -f infra/docker/Dockerfile -t gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest .
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY client/package.json client/package-lock.json ./client/
|
||||
RUN npm ci --omit=dev
|
||||
WORKDIR /app/client
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY client/package.json client/package-lock.json ./client/
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/client/node_modules ./client/node_modules
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY db ./db
|
||||
COPY client ./client
|
||||
RUN npm run build --prefix client
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5173
|
||||
ENV HOST=0.0.0.0
|
||||
ENV IMAGE_DIR=/app/data/images
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs gallery
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY db ./db
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
RUN mkdir -p /app/data/images && chown -R gallery:nodejs /app
|
||||
|
||||
USER gallery
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Build and push the production web image to Gitea over LAN (fast path).
|
||||
param(
|
||||
[string]$Tag = "latest",
|
||||
[switch]$SkipHosts,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Registry = "gitea.mysuperlab.netcraze.pro"
|
||||
$Image = "$Registry/danilka/gallery-web"
|
||||
|
||||
function Test-DockerRunning {
|
||||
docker info 2>&1 | Out-Null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
Write-Host "=== Build + LAN push to Gitea ===" -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-DockerRunning)) {
|
||||
Write-Host "Docker is not running. Start Docker Desktop and retry." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Write-Host ""
|
||||
Write-Host "Building ${Image}:${Tag} ..."
|
||||
docker build -f infra/docker/Dockerfile -t "${Image}:${Tag}" .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Write-Host "Build complete." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Skipping build (-SkipBuild)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
& "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,19 @@
|
||||
# Manual docker compose on a host with infra/docker/.env.prod present.
|
||||
# Prefer compose.truenas.yaml for TrueNAS Custom App UI.
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/Dockerfile
|
||||
image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest
|
||||
container_name: gallery-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:5173"
|
||||
env_file:
|
||||
- .env.prod
|
||||
volumes:
|
||||
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,34 @@
|
||||
# Paste into TrueNAS: Apps → Custom App → Install via Docker Compose
|
||||
#
|
||||
# BEFORE deploy:
|
||||
# 1. npm run docker:publish on dev PC (image must exist in Gitea)
|
||||
# 2. Replace YOUR_POSTGRES_PASSWORD below
|
||||
# 3. Gitea pull credentials on TrueNAS (read:package token)
|
||||
# 4. mkdir + chown image dir; npm run images:sync-to-prod
|
||||
# 5. Keenetic: gallery.mysuperlab.netcraze.pro → 192.168.10.122:5173, protocol http
|
||||
#
|
||||
# See infra/docker/DEPLOY-truenas.md and Documentation/environments.md
|
||||
services:
|
||||
web:
|
||||
image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest
|
||||
container_name: gallery-web
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "5173"
|
||||
HOST: "0.0.0.0"
|
||||
DB_HOST: "192.168.10.122"
|
||||
DB_PORT: "5432"
|
||||
DB_USER: gallery
|
||||
DB_PASSWORD: gallery
|
||||
DB_NAME: gallery_prod
|
||||
PUBLIC_URL: https://gallery.mysuperlab.netcraze.pro
|
||||
TRUST_PROXY: "true"
|
||||
IMAGE_DIR: /app/data/images
|
||||
volumes:
|
||||
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Push Docker image to Gitea over LAN (avoids KeenDNS hairpin slow upload).
|
||||
param(
|
||||
[string]$Tag = "latest",
|
||||
[switch]$SkipHosts
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Registry = "gitea.mysuperlab.netcraze.pro"
|
||||
$LanIp = "192.168.10.122"
|
||||
$Image = "$Registry/danilka/gallery-web"
|
||||
$HostsPath = "$env:SystemRoot\System32\drivers\etc\hosts"
|
||||
$HostsMarker = "# gallery-gitea-lan-push"
|
||||
|
||||
function Test-Admin {
|
||||
$current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-GiteaDnsAddress {
|
||||
try {
|
||||
return [System.Net.Dns]::GetHostAddresses($Registry) |
|
||||
Where-Object { $_.AddressFamily -eq 'InterNetwork' } |
|
||||
Select-Object -First 1 -ExpandProperty IPAddressToString
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "=== Gitea LAN push ===" -ForegroundColor Cyan
|
||||
$resolved = Get-GiteaDnsAddress
|
||||
Write-Host "DNS resolves $Registry -> $resolved"
|
||||
if ($resolved -and $resolved -ne $LanIp) {
|
||||
Write-Host "Public/hairpin path detected (slow push). LAN override recommended." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if (-not $SkipHosts) {
|
||||
$hostsContent = Get-Content $HostsPath -Raw -ErrorAction SilentlyContinue
|
||||
$hasEntry = $hostsContent -match "gitea\.mysuperlab\.netcraze\.pro"
|
||||
if (-not $hasEntry) {
|
||||
if (-not (Test-Admin)) {
|
||||
Write-Host ""
|
||||
Write-Host "Re-run as Administrator to add hosts entry, or add manually:" -ForegroundColor Yellow
|
||||
Write-Host " $LanIp $Registry"
|
||||
Write-Host " Then: ipconfig /flushdns"
|
||||
exit 1
|
||||
}
|
||||
Add-Content -Path $HostsPath -Value "`n$HostsMarker`n$LanIp $Registry" -Encoding ASCII
|
||||
ipconfig /flushdns | Out-Null
|
||||
Write-Host "Added hosts: $LanIp -> $Registry" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Hosts entry for gitea already present." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
$after = Get-GiteaDnsAddress
|
||||
Write-Host "After override, resolves to: $after"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Pushing ${Image}:${Tag} ..."
|
||||
docker push "${Image}:${Tag}"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Remove hosts entry ($HostsMarker) when finished if you no longer need LAN override." -ForegroundColor Cyan
|
||||
@@ -0,0 +1,17 @@
|
||||
# Save image to tar for fast SMB copy to TrueNAS (offline registry bypass).
|
||||
param(
|
||||
[string]$Tag = "latest",
|
||||
[string]$OutFile = "gallery-web.tar"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Image = "gitea.mysuperlab.netcraze.pro/danilka/gallery-web:${Tag}"
|
||||
|
||||
Write-Host "Saving $Image to $OutFile ..."
|
||||
docker save -o $OutFile $Image
|
||||
$sizeMb = [math]::Round((Get-Item $OutFile).Length / 1MB, 1)
|
||||
Write-Host "Saved $OutFile ($sizeMb MB)"
|
||||
Write-Host ""
|
||||
Write-Host "On TrueNAS shell:"
|
||||
Write-Host " sudo docker load -i /path/to/$OutFile"
|
||||
Write-Host " sudo docker tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:$Tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Load a docker save tarball on TrueNAS and tag for the Gallery Custom App.
|
||||
set -euo pipefail
|
||||
|
||||
TAR_PATH="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
IMAGE="gitea.mysuperlab.netcraze.pro/danilka/gallery-web"
|
||||
|
||||
if [[ -z "$TAR_PATH" || ! -f "$TAR_PATH" ]]; then
|
||||
echo "Usage: sudo bash truenas-load-image.sh /path/to/gallery-web.tar [tag]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker load -i "$TAR_PATH"
|
||||
docker tag "${IMAGE}:${TAG}" "${IMAGE}:latest"
|
||||
docker images "${IMAGE}"
|
||||
echo "Set Custom App pull_policy to IfNotPresent, then restart gallery-web."
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on TrueNAS shell once before first deploy.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_ROOT="/mnt/BasePool/Applications/Gallery/data/images"
|
||||
|
||||
mkdir -p "${IMAGE_ROOT}/portraits" "${IMAGE_ROOT}/paintings/thumbs"
|
||||
chown -R 1001:1001 "/mnt/BasePool/Applications/Gallery"
|
||||
chmod -R u+rwX,g+rwX "/mnt/BasePool/Applications/Gallery"
|
||||
|
||||
echo "Created ${IMAGE_ROOT} (owner uid 1001 = gallery user in container)"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-deploy checks — run on TrueNAS shell after Custom App is running.
|
||||
set -euo pipefail
|
||||
|
||||
PUBLIC_URL="${PUBLIC_URL:-http://gallery.mysuperlab.netcraze.pro}"
|
||||
LOCAL_URL="${LOCAL_URL:-http://127.0.0.1:5173}"
|
||||
|
||||
echo "== Local container =="
|
||||
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${LOCAL_URL}/" || echo "FAIL: container not reachable on :5173"
|
||||
|
||||
echo "== API bounds =="
|
||||
curl -sf "${LOCAL_URL}/api/bounds" || echo "FAIL: /api/bounds"
|
||||
|
||||
echo "== Public URL =="
|
||||
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${PUBLIC_URL}/" || echo "FAIL: public URL not reachable"
|
||||
|
||||
echo "== Container status =="
|
||||
docker ps --filter name=gallery-web --format '{{.Names}} {{.Status}}' || true
|
||||
|
||||
echo "== Image volume =="
|
||||
ls -la /mnt/BasePool/Applications/Gallery/data/images/paintings 2>/dev/null | head -5 || echo "WARN: paintings dir missing"
|
||||
|
||||
echo "Done. Open ${PUBLIC_URL}/ in a browser."
|
||||
@@ -0,0 +1,42 @@
|
||||
# Sync production image files from TrueNAS to dev repo.
|
||||
#
|
||||
# Usage:
|
||||
# npm run images:sync-from-prod
|
||||
# .\infra\scripts\sync-images-from-prod.ps1
|
||||
|
||||
param(
|
||||
[string]$Source = "\\192.168.10.122\Gallery\data\images",
|
||||
[string]$Dest = (Join-Path $PSScriptRoot "..\..\data\images"),
|
||||
[switch]$SkipConfirm
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if (-not (Test-Path $Dest)) {
|
||||
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
|
||||
}
|
||||
$Dest = (Resolve-Path $Dest).Path
|
||||
|
||||
Write-Host "Source: $Source"
|
||||
Write-Host "Dest: $Dest"
|
||||
|
||||
if (-not $SkipConfirm) {
|
||||
$confirm = Read-Host "Copy all files from prod (skip older)? Type yes"
|
||||
if ($confirm -ne "yes") {
|
||||
Write-Host "Aborted."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Source)) {
|
||||
Write-Error "Source not found: $Source"
|
||||
exit 1
|
||||
}
|
||||
|
||||
robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ge 8) {
|
||||
Write-Error "robocopy failed with exit code $code"
|
||||
exit $code
|
||||
}
|
||||
|
||||
Write-Host "Image sync complete (robocopy exit $code)."
|
||||
@@ -0,0 +1,49 @@
|
||||
# Sync dev image files to TrueNAS production volume.
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# npm run images:sync-to-prod
|
||||
# Copies data/images/ → \\192.168.10.122\Gallery\data\images
|
||||
# Map the share first if needed: net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
|
||||
|
||||
param(
|
||||
[string]$Source = (Join-Path $PSScriptRoot "..\..\data\images"),
|
||||
[string]$Dest = "\\192.168.10.122\Gallery\data\images",
|
||||
[switch]$SkipConfirm
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Source = (Resolve-Path $Source -ErrorAction Stop).Path
|
||||
|
||||
Write-Host "Source: $Source"
|
||||
Write-Host "Dest: $Dest"
|
||||
|
||||
if (-not $SkipConfirm) {
|
||||
$confirm = Read-Host "Copy all files (skip older)? Type yes"
|
||||
if ($confirm -ne "yes") {
|
||||
Write-Host "Aborted."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
$smbRoot = "\\192.168.10.122\Gallery"
|
||||
if ($env:SMB_USER -and $env:SMB_PASSWORD) {
|
||||
Write-Host "Mapping $sbmRoot ..."
|
||||
net use $sbmRoot /user:$env:SMB_USER $env:SMB_PASSWORD 2>&1 | Out-Host
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $Dest)) {
|
||||
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Note: could not pre-create dest (will rely on robocopy): $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ge 8) {
|
||||
Write-Error "robocopy failed with exit code $code"
|
||||
exit $code
|
||||
}
|
||||
|
||||
Write-Host "Image sync complete (robocopy exit $code)."
|
||||
Reference in New Issue
Block a user