Act on the five-agent review of the wilm import
Findings the reviewers verified, fixed here. The larger cluster they found is NOT fixed and is listed at the end. Frontend. reportImageUrl is called from the template, so it ran on every render with no in-flight guard: a report whose fetch was pending was re-requested, and because each resolution reassigns the map the whole grid re-rendered and re-fired the rest, costing up to N-squared requests for N thumbnails. Two blobs resolving for one key orphaned the first object URL where revoke could not reach it, and a failure was never recorded so a broken image retried forever. Guarded, deduplicated, failures remembered. Three CSS variables did not exist, so their hardcoded fallbacks always won: --bg-alt (the "No screenshot" tile, near-black in light mode), and --warning-text/-bg/-border (dark khaki on a dark modal). Replaced with real tokens. The DEV badge's literal #c62828 is var(--danger). Installer and launcher. The generated site installer ran docker compose with no -p, so the project name defaulted to the package folder - which carries the version. Installing the next version created a new project with new empty volumes and the site came up looking wiped, old data stranded under the old project. Pinned to shopdb-<channel>. Start-ShopDBDev deleted any existing -TargetDir that lacked a .git, recursively and without asking. For a script handed round as "save it anywhere and run it", a mistyped -TargetDir pointed at Documents would have destroyed it. It now only reclaims a directory that is empty or carries this launcher's own marker, and refuses otherwise. Dev secrets came from Get-Random, which is System.Random: a 32-bit seed, and all four values off one stream, so recovering one recovers the rest. They sign JWTs for a stack a pilot may point at real imported data. CSPRNG now, and the API port binds to loopback like the DB rather than every interface. Two of my own from earlier in this session. The operator-report size check sat below request.form, and the first access to that parses and spools the whole multipart - so it ran after the thing it prevents. Moved above. And the channel remap missed build-channel-installer.ps1 entirely, where it set BOTH channels to main: a "stable" package built from the trunk, the exact mislabel the remap was for. stable now resolves the newest v* tag. Also removed a zero-byte docs/ShopDB-Planner-Tasks-v2.csv that rode in. NOT FIXED - needs a decision, and it is the biggest thing the review found. Migrations 0006/0007 freeze the originating site's fleet convention into product seed: a shipped policy literally named "RPCA Shopfloor App Monitors", a hardcoded 'Shopfloor PC' type deciding who gets the shipped watch-list, and shipped rows the API refuses to edit or deactivate - including their launch paths. A site whose eDNC is on another drive cannot correct it and gets permanent false drift. Those are stamped migrations, so this gets harder to undo at every install. See the review notes before anyone upgrades a second site through 0006-0007. Suite 2314 passing, frontend 316, all three PowerShell files parse. Scope: product
This commit is contained in:
@@ -83,7 +83,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-5001}:5001"
|
# Loopback only, same as the DB below. This stack generates its own
|
||||||
|
# dev secrets and may hold a real imported dataset; it has no business
|
||||||
|
# listening on every interface of a laptop on a shop-floor network.
|
||||||
|
- "127.0.0.1:${API_PORT:-5001}:5001"
|
||||||
volumes:
|
volumes:
|
||||||
- instance_data:/app/instance
|
- instance_data:/app/instance
|
||||||
|
|
||||||
|
|||||||
@@ -256,19 +256,38 @@ function formatDate(value) {
|
|||||||
return formatValue(value, { format: 'date' })
|
return formatValue(value, { format: 'date' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called from the template, so it runs on EVERY render. Without the seen-set it
|
||||||
|
// re-fired the GET for a report whose fetch was still in flight, and because
|
||||||
|
// each resolution reassigns reportImageUrls the whole grid re-rendered and
|
||||||
|
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
|
||||||
|
// Two blobs resolving for one key also orphaned the first object URL, which
|
||||||
|
// revokeReportImageUrls could not free because only the surviving one was in
|
||||||
|
// the map. A failure was never recorded either, so a broken image retried for
|
||||||
|
// the life of the page.
|
||||||
|
//
|
||||||
|
// requestedReportImages tracks "we have asked", separately from the map of
|
||||||
|
// resolved URLs, and is cleared alongside it.
|
||||||
|
const requestedReportImages = new Set()
|
||||||
|
|
||||||
function reportImageUrl(panel, report) {
|
function reportImageUrl(panel, report) {
|
||||||
const key = report.operatorreportid
|
const key = report.operatorreportid
|
||||||
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
|
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
|
||||||
|
if (requestedReportImages.has(key)) return ''
|
||||||
|
requestedReportImages.add(key)
|
||||||
const base = panel.imagebase || '/api/endpoints/operatorreports'
|
const base = panel.imagebase || '/api/endpoints/operatorreports'
|
||||||
const path = toApiPath(base + '/' + key + '/image', props.assetid)
|
const path = toApiPath(base + '/' + key + '/image', props.assetid)
|
||||||
api.get(path, { responseType: 'blob' })
|
api.get(path, { responseType: 'blob' })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
reportImageUrls.value = {
|
const url = URL.createObjectURL(response.data)
|
||||||
...reportImageUrls.value,
|
// Another render may have resolved first; keep one URL per key and free
|
||||||
[key]: URL.createObjectURL(response.data),
|
// the loser rather than orphaning it.
|
||||||
|
if (reportImageUrls.value[key]) {
|
||||||
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { /* stays in requestedReportImages: do not retry every render */ })
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +298,7 @@ function revokeReportImageUrls() {
|
|||||||
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
||||||
})
|
})
|
||||||
reportImageUrls.value = {}
|
reportImageUrls.value = {}
|
||||||
|
requestedReportImages.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(revokeReportImageUrls)
|
onUnmounted(revokeReportImageUrls)
|
||||||
@@ -668,7 +688,7 @@ defineExpose({ refresh: load, populatedGroups })
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--bg-alt, #1b1b1b);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ onUnmounted(() => {
|
|||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: #c62828;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -310,9 +310,9 @@ async function remove(p) {
|
|||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--warning-text, #8a6d3b);
|
color: var(--warning-dark);
|
||||||
background: var(--warning-bg, rgba(240, 173, 78, 0.12));
|
background: var(--bg);
|
||||||
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4));
|
border: 1px solid var(--warning);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1851,6 +1851,20 @@ def upload_operator_report():
|
|||||||
if error is not None:
|
if error is not None:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
|
# Refuse an oversized body BEFORE anything parses it. content_length is a
|
||||||
|
# header, so it is readable without touching the body; request.form is not
|
||||||
|
# - the first access to it makes werkzeug parse the whole multipart and
|
||||||
|
# spool the file part to a temp file. An earlier version of this check sat
|
||||||
|
# below that line and so ran after the thing it exists to prevent. The
|
||||||
|
# value is caller-supplied, which is why this is an early out and not the
|
||||||
|
# enforcement; the bounded read below is.
|
||||||
|
declared = request.content_length or 0
|
||||||
|
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'screenshot exceeds the {} byte limit'.format(
|
||||||
|
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||||
|
|
||||||
hostname = (request.form.get('hostname') or '').strip()
|
hostname = (request.form.get('hostname') or '').strip()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
@@ -1869,18 +1883,6 @@ def upload_operator_report():
|
|||||||
imagemimetype = None
|
imagemimetype = None
|
||||||
imagebytesize = None
|
imagebytesize = None
|
||||||
|
|
||||||
# Refuse an oversized body BEFORE reading it. The cap used to be checked
|
|
||||||
# after upload.read(), which pulls the whole thing into memory first - the
|
|
||||||
# check could not prevent what it was there to prevent. content_length is
|
|
||||||
# caller-supplied so it is a cheap early out, not the enforcement; the read
|
|
||||||
# below is still bounded.
|
|
||||||
declared = request.content_length or 0
|
|
||||||
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
|
|
||||||
return error_response(
|
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
|
||||||
'screenshot exceeds the {} byte limit'.format(
|
|
||||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
|
||||||
|
|
||||||
upload = request.files.get('screenshot')
|
upload = request.files.get('screenshot')
|
||||||
if upload is not None and upload.filename:
|
if upload is not None and upload.filename:
|
||||||
# Read one byte past the cap: enough to know it was exceeded, without
|
# Read one byte past the cap: enough to know it was exceeded, without
|
||||||
|
|||||||
@@ -85,11 +85,27 @@ function Clear-InterruptedRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# A directory that exists but has no valid .git is a broken/partial clone;
|
# A directory that exists but has no valid .git is a broken/partial clone -
|
||||||
# remove it so the clone below can start fresh instead of failing.
|
# USUALLY. It is also what -TargetDir pointed at somebody's Documents folder
|
||||||
|
# looks like, and this ran Remove-Item -Recurse -Force on it without asking.
|
||||||
|
# For a script distributed as "save it anywhere and run it", a mistyped
|
||||||
|
# -TargetDir must not be able to delete a user's files.
|
||||||
|
#
|
||||||
|
# Only reclaim a directory this script can show it owns: empty, or carrying
|
||||||
|
# the marker written after a successful clone. Anything else stops.
|
||||||
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
|
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
|
||||||
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
|
$ours = Test-Path (Join-Path $Target $script:CloneMarker)
|
||||||
Remove-Item -Path $Target -Recurse -Force
|
$empty = -not (Get-ChildItem -LiteralPath $Target -Force |
|
||||||
|
Select-Object -First 1)
|
||||||
|
if ($ours -or $empty) {
|
||||||
|
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
|
||||||
|
Remove-Item -Path $Target -Recurse -Force
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw ("$Target already exists, is not empty, and was not created " +
|
||||||
|
"by this launcher. Refusing to delete it. Pass -TargetDir " +
|
||||||
|
'pointing somewhere else, or remove it yourself if you are sure.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# A clone created by an elevated (Administrator) run is owned by
|
# A clone created by an elevated (Administrator) run is owned by
|
||||||
@@ -104,8 +120,18 @@ function Clear-InterruptedRun {
|
|||||||
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
|
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
|
||||||
finally { $ErrorActionPreference = $previous }
|
finally { $ErrorActionPreference = $previous }
|
||||||
if ($probe -match 'dubious ownership') {
|
if ($probe -match 'dubious ownership') {
|
||||||
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
|
# Same rule as above: a .git directory proves this is a clone, but
|
||||||
Remove-Item -Path $Target -Recurse -Force
|
# not that it is OUR clone, and re-cloning discards whatever is in
|
||||||
|
# it. Only reclaim one this launcher created.
|
||||||
|
if (Test-Path (Join-Path $Target $script:CloneMarker)) {
|
||||||
|
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
|
||||||
|
Remove-Item -Path $Target -Recurse -Force
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw ("The git clone at $Target has mismatched ownership and " +
|
||||||
|
'was not created by this launcher, so it is not being ' +
|
||||||
|
'deleted. Remove it yourself, or pass -TargetDir elsewhere.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +140,9 @@ function Clear-InterruptedRun {
|
|||||||
# stored GitHub credential for github.com, so clone/fetch authenticate against
|
# stored GitHub credential for github.com, so clone/fetch authenticate against
|
||||||
# the private repo without anything sensitive touching this script or disk.
|
# the private repo without anything sensitive touching this script or disk.
|
||||||
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
|
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
|
||||||
|
# Written into a clone this launcher made, so a later run can tell its own
|
||||||
|
# working directory apart from one that happens to sit at the same path.
|
||||||
|
$script:CloneMarker = '.shopdb-launcher'
|
||||||
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
|
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
|
||||||
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
|
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
|
||||||
# does not exist here - the launcher would have cloned a missing branch.
|
# does not exist here - the launcher would have cloned a missing branch.
|
||||||
|
|||||||
@@ -80,14 +80,29 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
# Channel -> (branch, badge label). The single source of channel truth, mirrored
|
# Channel -> (ref, badge label). The single source of channel truth, mirrored
|
||||||
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
|
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
|
||||||
# channel means.
|
# channel means.
|
||||||
|
#
|
||||||
|
# stable has NO fixed ref: a release here is a tag, not a branch, so it is
|
||||||
|
# resolved below from the newest v* tag. Writing 'main' here would build the
|
||||||
|
# trunk and hand it to a site under a word that promises a release - the same
|
||||||
|
# mislabel this repo's channel mapping was corrected for, missed in this file
|
||||||
|
# the first time round.
|
||||||
$channels = @{
|
$channels = @{
|
||||||
stable = @{ Ref = 'main'; EnvLabel = '' }
|
stable = @{ Ref = $null; EnvLabel = '' }
|
||||||
edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
|
edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Resolve-StableRef {
|
||||||
|
# Newest release tag by version sort. Falls back to main with a warning
|
||||||
|
# rather than silently building something and calling it stable.
|
||||||
|
$tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1)
|
||||||
|
if ($tag) { return $tag }
|
||||||
|
Write-Warning 'No v* tag in this repo; building stable from main.'
|
||||||
|
return 'main'
|
||||||
|
}
|
||||||
|
|
||||||
function Assert-LastExit([string]$What) {
|
function Assert-LastExit([string]$What) {
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
|
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
|
||||||
}
|
}
|
||||||
@@ -100,7 +115,11 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$config = $channels[$Channel]
|
$config = $channels[$Channel]
|
||||||
$branch = if ($Ref) { $Ref } else { $config.Ref }
|
# An explicit -Ref always wins. Otherwise the channel decides: edge is the
|
||||||
|
# trunk, stable resolves the newest release tag.
|
||||||
|
$branch = if ($Ref) { $Ref }
|
||||||
|
elseif ($config.Ref) { $config.Ref }
|
||||||
|
else { Resolve-StableRef }
|
||||||
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
|
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
|
||||||
|
|
||||||
$appImage = "shopdb-flask:$Version"
|
$appImage = "shopdb-flask:$Version"
|
||||||
@@ -271,8 +290,15 @@ Write-Host "==> Loading image bundle" -ForegroundColor Cyan
|
|||||||
docker load -i $bundle.FullName
|
docker load -i $bundle.FullName
|
||||||
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
|
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
|
||||||
|
|
||||||
|
# -p is NOT optional here. Without it compose names the project after the
|
||||||
|
# containing folder, which is shopdb-<channel>-<version> - so installing the
|
||||||
|
# next version creates a NEW project with new empty named volumes, and the site
|
||||||
|
# comes up looking wiped. The old data is still there under the old project's
|
||||||
|
# volumes, but nothing carries it across. Pin the project to the channel so an
|
||||||
|
# upgrade lands on the same volumes.
|
||||||
$composeArgs = @(
|
$composeArgs = @(
|
||||||
'compose', '--env-file', '.env',
|
'compose', '--env-file', '.env',
|
||||||
|
'-p', 'shopdb-__CHANNEL__',
|
||||||
'-f', 'docker-compose.airgap.yml',
|
'-f', 'docker-compose.airgap.yml',
|
||||||
'-f', 'docker-compose.channel.yml'
|
'-f', 'docker-compose.channel.yml'
|
||||||
)
|
)
|
||||||
@@ -289,6 +315,11 @@ Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green
|
|||||||
Write-Host "Seed the first admin (once):"
|
Write-Host "Seed the first admin (once):"
|
||||||
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
|
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
|
||||||
'@
|
'@
|
||||||
|
# The here-string above is single-quoted so nothing in it interpolates - that
|
||||||
|
# is deliberate, the installer is full of $ that must survive verbatim. The
|
||||||
|
# compose project name is the one value that has to be baked per channel, so
|
||||||
|
# substitute it here rather than switching the whole block to interpolation.
|
||||||
|
$installer = $installer.Replace('__CHANNEL__', $Channel)
|
||||||
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
|
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
|
||||||
|
|
||||||
# --- 6. README ----------------------------------------------------------
|
# --- 6. README ----------------------------------------------------------
|
||||||
|
|||||||
@@ -282,10 +282,20 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
# Dev-grade secrets: generated locally, never for production. A real
|
# Dev-grade secrets: generated locally, never for production. A real
|
||||||
# deploy fills .env from the wiki runbook, not from this script.
|
# deploy fills .env from the wiki runbook, not from this script.
|
||||||
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
#
|
||||||
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
# Get-Random is System.Random: a 32-bit seed, and all four values came
|
||||||
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
# off one stream, so recovering the seed from any one of them recovers
|
||||||
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
# the other three. These sign JWTs for a stack a pilot may point at a
|
||||||
|
# real imported dataset, so they come from the CSPRNG instead. "Dev
|
||||||
|
# grade" is about rotation and provenance, not about being guessable.
|
||||||
|
$secret = [Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
|
||||||
|
$jwt = [Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
|
||||||
|
$dbPass = ([Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
|
||||||
|
$rootPass = ([Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
|
||||||
$apiPort = $config.ApiPort
|
$apiPort = $config.ApiPort
|
||||||
$mysqlPort = $config.MysqlPort
|
$mysqlPort = $config.MysqlPort
|
||||||
$image = "$imageRepo`:$($config.ImageTag)"
|
$image = "$imageRepo`:$($config.ImageTag)"
|
||||||
|
|||||||
Reference in New Issue
Block a user