Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
116
.claude/INDEX.md
Normal file
116
.claude/INDEX.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# ShopDB Index - Load Only What You Need
|
||||
|
||||
Quick pointers to avoid reading entire wiki or codebase.
|
||||
|
||||
## When Working On...
|
||||
|
||||
**Plugin development**:
|
||||
- Read: `CLAUDE.md` (overview)
|
||||
- Grep: `class BasePlugin` in `shopdb/api/__init__.py`
|
||||
- Reference: `../shopdb-flask.wiki/ADR-001-asset-as-platform-contract.md`
|
||||
|
||||
**Database migrations**:
|
||||
- Read: `../shopdb-flask.wiki/ADR-008-plugin-migration-ownership.md`
|
||||
- Grep: `PLUGIN_TABLE_OWNERS` for dependency order
|
||||
- Pattern: `plugins/{name}/migrations/versions/*.py`
|
||||
|
||||
**Site-specific config**:
|
||||
- Read: `../shopdb-flask.wiki/ADR-015-site-specific-configuration.md`
|
||||
- Locations: `shopdb/core/settings/`, `shopdb/core/seed/`, `scripts/site_imports/`
|
||||
|
||||
**Kiosk/device auth**:
|
||||
- Read: `../shopdb-flask.wiki/ADR-016-credential-delivery.md`
|
||||
- Grep: `requires_device_token`
|
||||
|
||||
**Deployment/branches**:
|
||||
- Read: `../shopdb-flask.wiki/ADR-004-deployment-topology.md`
|
||||
- Scripts: `~/Documents/github/*.cmd`
|
||||
|
||||
**Frontend plugin panels**:
|
||||
- Read: `../shopdb-flask.wiki/ADR-010-frontend-plugin-hooks.md`
|
||||
- Pattern: `plugins/{name}/frontend/views/*.vue`
|
||||
|
||||
**Testing**:
|
||||
- Read: `tests/conftest.py` for fixtures
|
||||
- Pattern: `tests/test_plugins/test_{plugin}_{feature}.py`
|
||||
|
||||
## Plugin Map (20 plugins)
|
||||
|
||||
Core asset types:
|
||||
- `computers`, `endpoints`, `printers`, `machines`, `measuringtools`, `network`, `tools`
|
||||
|
||||
Workflows:
|
||||
- `printedparts`, `usb`, `slides`, `notifications`
|
||||
|
||||
Integrations:
|
||||
- `warranty`, `geenforce`, `guides`, `knowledgebase`
|
||||
|
||||
Admin:
|
||||
- `employees`, `backups`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `shopdb/api/__init__.py` - Stable import surface (75 lines)
|
||||
- `shopdb/config.py` - App configuration
|
||||
- `shopdb/core/models/asset.py` - Base asset model
|
||||
- `docker-compose.yml` - Dev stack definition
|
||||
- `scripts/check-naming-and-style.sh` - Pre-commit hook
|
||||
|
||||
## Git Branches
|
||||
|
||||
**Current repo state**:
|
||||
- `wj` (checked out) - Prod branch for WJ shop floor
|
||||
- `main` - Public GitHub mirror, deploys nowhere
|
||||
- `origin/wilm` - Upstream changes from benjamin.baker (86 commits ahead)
|
||||
- `origin/dev-be` - Historical dev branch
|
||||
|
||||
**Branch workflow** (ADR-004):
|
||||
- `wj` -> prod deployment (WJ site)
|
||||
- `dev` -> ops deployment (monitoring/pilot sites, on gitea only)
|
||||
- `main` -> GitHub public mirror only (no deployments)
|
||||
- `wilm` -> fetch from GitHub, review, merge to wj or dev
|
||||
|
||||
**On this work PC**:
|
||||
- Has GitHub credentials (ge-aero/shopdb-flask)
|
||||
- Fetch wilm: `~/Documents/github/fetch-wilm-branch.cmd`
|
||||
- Bundle to dev PC: `~/Documents/github/pull-shopdb-bundle.cmd`
|
||||
|
||||
## Common Paths
|
||||
|
||||
- Plugin code: `plugins/{name}/`
|
||||
- Core models: `shopdb/core/models/`
|
||||
- Core API: `shopdb/core/api/`
|
||||
- Frontend core: `frontend/src/`
|
||||
- Tests: `tests/test_plugins/`
|
||||
- Migrations: `migrations/` (core) + `plugins/{name}/migrations/`
|
||||
- Scripts: `scripts/`
|
||||
- Docs: `../shopdb-flask.wiki/`
|
||||
|
||||
## Avoid Reading These Unless Needed
|
||||
|
||||
- `docs/openapi.json` (489 lines, generated)
|
||||
- `frontend/dist/` (build output)
|
||||
- `node_modules/`, `venv/`, `.pytest_cache/`
|
||||
- `agent/` (C# endpoint agent, 10k+ lines)
|
||||
- Full wiki (68 files) - use index above
|
||||
|
||||
## Token-Efficient Patterns
|
||||
|
||||
1. **Grep before Read**: Find the exact file/function first
|
||||
2. **Read with limit**: Use offset/limit for large files
|
||||
3. **Reference by path**: Cite `file:line` instead of quoting code
|
||||
4. **Use memory**: Store repeated patterns in memory files
|
||||
5. **Wiki on-demand**: Only read specific ADRs when relevant
|
||||
|
||||
## One-Line ADR Summaries
|
||||
|
||||
- ADR-001: Asset is the platform primitive for all equipment
|
||||
- ADR-004: Three air-gapped environments, bundle workflow
|
||||
- ADR-006: Endpoint agent sends telemetry, receives commands
|
||||
- ADR-007: Semantic versioning, GitHub releases
|
||||
- ADR-008: Each plugin owns its migration chain, no conflicts
|
||||
- ADR-009: Frontend plugins gate on backend features
|
||||
- ADR-010: Plugins register Vue components via manifest
|
||||
- ADR-013: Lean builds include only needed plugins
|
||||
- ADR-015: Site literals go in settings/seed/site_imports only
|
||||
- ADR-016: Kiosks use device tokens, not user sessions
|
||||
152
.claude/QUICK-REFERENCE.md
Normal file
152
.claude/QUICK-REFERENCE.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# ShopDB Quick Reference
|
||||
|
||||
Fast lookup for architectural decisions and contracts.
|
||||
|
||||
## Critical ADRs (in wiki)
|
||||
|
||||
- **ADR-001**: Asset as platform contract - Core asset model, BaseAsset, composition
|
||||
- **ADR-004**: Deployment topology - Air-gapped sites, bundle workflow
|
||||
- **ADR-006**: Collector contract - Endpoint agent protocol
|
||||
- **ADR-007**: Product versioning - Semantic versioning, release process
|
||||
- **ADR-008**: Plugin migration ownership - PLUGIN_TABLE_OWNERS, migration chains
|
||||
- **ADR-013**: Plugin catalog - Lean builds, plugin manifest
|
||||
- **ADR-015**: Site-specific configuration - Where site literals belong
|
||||
- **ADR-016**: Credential delivery - Device tokens, kiosk auth
|
||||
|
||||
## Import Surface (stable API)
|
||||
|
||||
```python
|
||||
from shopdb.api import (
|
||||
# Database
|
||||
db,
|
||||
|
||||
# Core Models
|
||||
Asset, Employee, User, Building, MapLevel,
|
||||
|
||||
# Plugin Base
|
||||
BasePlugin,
|
||||
|
||||
# Auth/Authz
|
||||
requires_role, requires_device_token,
|
||||
|
||||
# Utilities
|
||||
badges, datevalues, queryargs
|
||||
)
|
||||
```
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
```
|
||||
plugins/{name}/
|
||||
|-- plugin.py # BasePlugin subclass, manifest
|
||||
|-- models/ # SQLAlchemy models
|
||||
| +-- {name}.py
|
||||
|-- api/ # Flask routes
|
||||
| +-- routes.py
|
||||
|-- frontend/ # Vue components
|
||||
| +-- views/
|
||||
| |-- {Name}List.vue
|
||||
| |-- {Name}Detail.vue
|
||||
| +-- {Name}Form.vue
|
||||
|-- migrations/ # Alembic migrations
|
||||
| +-- versions/
|
||||
+-- services/ # Business logic (optional)
|
||||
```
|
||||
|
||||
## Database Naming
|
||||
|
||||
- **Tables**: `printedpart`, `mappeddrives` (lowercase concatenated)
|
||||
- **Columns**: `serial_number`, `last_seen` (snake_case)
|
||||
- **Enforced**: `scripts/check-naming-and-style.sh`
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `wj` -> prod (WJ shop floor)
|
||||
- `dev` -> ops (monitoring/pilot)
|
||||
- `main` -> public mirror (GitHub only)
|
||||
- `wilm` -> upstream (benjamin.baker's changes)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pytest -v
|
||||
|
||||
# Check style
|
||||
scripts/check-naming-and-style.sh
|
||||
|
||||
# Create migration
|
||||
cd plugins/{name}
|
||||
alembic revision -m "description"
|
||||
|
||||
# Dev server
|
||||
docker compose up
|
||||
|
||||
# Bundle for dev PC
|
||||
# (on work PC) ~/Documents/github/pull-shopdb-bundle.cmd
|
||||
|
||||
# Fetch wilm changes
|
||||
# (on work PC) ~/Documents/github/fetch-wilm-branch.cmd
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
```python
|
||||
def test_feature(client, db_session):
|
||||
# Setup
|
||||
item = Model(name="test")
|
||||
db_session.add(item)
|
||||
db_session.commit()
|
||||
|
||||
# Execute
|
||||
resp = client.get('/api/plugin/endpoint')
|
||||
|
||||
# Assert
|
||||
assert resp.status_code == 200
|
||||
assert resp.json['name'] == 'test'
|
||||
```
|
||||
|
||||
## Site Data Rules (ADR-015)
|
||||
|
||||
[OK] **Allowed**:
|
||||
- `shopdb/core/settings/buildings.py`
|
||||
- `shopdb/core/seed/*.sql`
|
||||
- `scripts/site_imports/*.py`
|
||||
|
||||
[FAIL] **Forbidden**:
|
||||
- Hardcoded in plugin code
|
||||
- Embedded in migrations
|
||||
- Configuration files
|
||||
|
||||
## Migration Ownership (ADR-008)
|
||||
|
||||
Each plugin owns its chain. Coordinate cross-plugin FK changes via PLUGIN_TABLE_OWNERS:
|
||||
|
||||
```python
|
||||
PLUGIN_TABLE_OWNERS = {
|
||||
'asset': 'core',
|
||||
'employee': 'employees',
|
||||
'pc': 'computers',
|
||||
'endpoint': 'endpoints',
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
## Device Tokens (ADR-016)
|
||||
|
||||
Kiosks require device authentication:
|
||||
|
||||
```python
|
||||
from shopdb.api import requires_device_token
|
||||
|
||||
@bp.route('/kiosk/endpoint')
|
||||
@requires_device_token(['usb_kiosk', 'parts_kiosk'])
|
||||
def kiosk_view():
|
||||
return render_template('kiosk.html')
|
||||
```
|
||||
|
||||
## Wiki Location
|
||||
|
||||
Full docs: `~/Documents/github/shopdb-flask.wiki/`
|
||||
|
||||
All ADRs, deployment guides, plugin development docs live there.
|
||||
27
.claude/settings.json
Normal file
27
.claude/settings.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"bash:pytest*",
|
||||
"bash:git status",
|
||||
"bash:git log*",
|
||||
"bash:git diff*",
|
||||
"bash:git branch*",
|
||||
"bash:docker compose*",
|
||||
"bash:docker ps",
|
||||
"bash:alembic*",
|
||||
"bash:python scripts/*",
|
||||
"grep:**",
|
||||
"glob:**"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"beforeCommit": [
|
||||
"scripts/check-naming-and-style.sh"
|
||||
]
|
||||
},
|
||||
"context": {
|
||||
"alwaysInclude": [
|
||||
".claude/INDEX.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
# Keep the build context lean and prevent host artifacts from clobbering
|
||||
# in-image installs. node_modules is platform-specific: copying the host's
|
||||
# (Windows) tree over the container's `npm ci` breaks rollup's native binary.
|
||||
# Dockerignore for pre-built frontend approach
|
||||
# frontend/dist is INCLUDED (not ignored) for this build
|
||||
|
||||
**/node_modules
|
||||
frontend/dist
|
||||
frontend/src/.plugins-staged
|
||||
frontend/src/router/routes.gen.js
|
||||
|
||||
# Python caches and virtualenvs
|
||||
# Python caches
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.venv
|
||||
venv
|
||||
|
||||
# Local dev and VCS noise
|
||||
# Local dev and VCS
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env.local
|
||||
|
||||
# Installer build artifacts (not part of the app image)
|
||||
# Installer artifacts
|
||||
installer-arch-dev/build
|
||||
installer-arch-dev/dist
|
||||
|
||||
22
.dockerignore.prebuilt
Normal file
22
.dockerignore.prebuilt
Normal file
@@ -0,0 +1,22 @@
|
||||
# Dockerignore for pre-built frontend approach
|
||||
# frontend/dist is INCLUDED (not ignored) for this build
|
||||
|
||||
**/node_modules
|
||||
frontend/src/.plugins-staged
|
||||
frontend/src/router/routes.gen.js
|
||||
|
||||
# Python caches
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.venv
|
||||
venv
|
||||
|
||||
# Local dev and VCS
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env.local
|
||||
|
||||
# Installer artifacts
|
||||
installer-arch-dev/build
|
||||
installer-arch-dev/dist
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -11,7 +11,7 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined.
|
||||
|
||||
## Installer Logic
|
||||
- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers.
|
||||
- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers.
|
||||
- The user's workflow: the agent hands over only the built EXEs (`shopdbagent.exe`, `shopdb-endpoint-tray.exe`); the user uploads them via the "Upload New/Legacy Agents" field in the Build Site Agent UI and builds the package with the newest version selected at the top.
|
||||
|
||||
## Naming (LOCKED - the hook rejects violations)
|
||||
|
||||
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# ShopDB Flask - Claude Code Context
|
||||
|
||||
ShopDB is a manufacturing floor asset management system built with Flask, Vue 3, and PostgreSQL, deployed across air-gapped GE Aerospace sites.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend**: Flask 3.x with SQLAlchemy 2.x, plugin-based architecture
|
||||
- **Frontend**: Vue 3 with Vite, component-based UI with plugin panels
|
||||
- **Database**: PostgreSQL 15+ with Alembic migrations per plugin
|
||||
- **Deployment**: Docker Compose with gunicorn, air-gapped sites via bundle workflow
|
||||
- **Plugins**: 20+ plugins (computers, endpoints, printers, machines, etc.)
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
### ADR-015: Site-Specific Configuration
|
||||
Site literals (building names, printer models, machine types) MUST live in:
|
||||
- `shopdb/core/settings/` - Site settings (buildings, roles, etc.)
|
||||
- `shopdb/core/seed/` - Initial data SQL
|
||||
- `scripts/site_imports/` - Import tools for Maximo/WINC data
|
||||
|
||||
NEVER hardcode site-specific data in plugin code or migrations.
|
||||
|
||||
### ADR-008: Plugin Migration Ownership
|
||||
Each plugin owns its migration chain in `plugins/{name}/migrations/versions/`.
|
||||
- Migrations run in dependency order defined in PLUGIN_TABLE_OWNERS
|
||||
- Each plugin's chain must be linear (no branches)
|
||||
- Migrations run automatically on deploy
|
||||
|
||||
### Database Naming Convention
|
||||
- **Tables**: lowercase concatenated, no underscores (e.g., `printedpart`, `mappeddrives`)
|
||||
- **Columns**: snake_case (e.g., `serial_number`, `last_seen`)
|
||||
- **Indexes/constraints**: follow PostgreSQL conventions
|
||||
- Enforced by `scripts/check-naming-and-style.sh` pre-commit hook
|
||||
|
||||
### Branch and Deployment Model
|
||||
- `wj` -> prod (shop floor at WJ site)
|
||||
- `dev` -> ops (monitoring/pilot sites)
|
||||
- `main` -> nothing (GitHub public mirror only)
|
||||
- Three air gaps: dev PC <-> work PC <-> GitHub
|
||||
- Bundles carry code across gaps (pull-shopdb-bundle.cmd, fetch-wilm-branch.cmd)
|
||||
|
||||
### Code Style
|
||||
- Plain ASCII only - no unicode, emojis, or em-dashes
|
||||
- No LLM attribution comments (no "Co-Authored-By: Claude")
|
||||
- Minimal comments - code should be self-documenting
|
||||
- No banned shorthand (e.g., "config" not "cfg", "initialize" not "init")
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
Plugins extend ShopDB via:
|
||||
- `plugin.py` - BasePlugin subclass with manifest, routes, hooks
|
||||
- `models/` - SQLAlchemy models
|
||||
- `api/` - Flask blueprints and routes
|
||||
- `frontend/views/` - Vue components auto-discovered by router
|
||||
- `migrations/versions/` - Alembic migrations
|
||||
|
||||
Import from `shopdb.api` for stable surfaces:
|
||||
```python
|
||||
from shopdb.api import db, Asset, Employee, BasePlugin
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation in the GitHub wiki at `../shopdb-flask.wiki/`:
|
||||
|
||||
**Critical ADRs**:
|
||||
- ADR-001: Asset as platform contract
|
||||
- ADR-004: Deployment topology (air-gapped sites)
|
||||
- ADR-006: Collector contract (endpoint agent)
|
||||
- ADR-007: Product versioning and releases
|
||||
- ADR-013: Plugin catalog and lean builds
|
||||
- ADR-016: Credential delivery
|
||||
|
||||
**References**:
|
||||
- `API-REFERENCE.md` - Core API surface
|
||||
- `PLUGIN-DEVELOPMENT.md` - Plugin authoring guide
|
||||
- `DEPLOYMENT.md` - Deploy runbooks per site
|
||||
- `TESTING.md` - Test patterns and CI
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Feature branches off `dev` or `wj`
|
||||
2. **Run tests**: `pytest -v` (REQUIRED before claiming completion)
|
||||
3. **Check naming/style**: `scripts/check-naming-and-style.sh` (REQUIRED before commit)
|
||||
4. Bundle for other PC or push to GitHub
|
||||
5. PR to `wj` (for prod) or `dev` (for ops)
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**DO NOT**:
|
||||
- Edit generated files (frontend/dist/, docs/openapi.json)
|
||||
- Change dependencies (requirements.txt, package.json) without approval
|
||||
- Commit code with unicode/emojis (plain ASCII only)
|
||||
- Hardcode site-specific data (buildings, models, etc.) - use settings/seed
|
||||
- Skip tests or pre-commit hooks
|
||||
- Break plugin migration chains (keep linear, coordinate via PLUGIN_TABLE_OWNERS)
|
||||
|
||||
**ALWAYS**:
|
||||
- Run `pytest -v` before claiming work complete
|
||||
- Run `scripts/check-naming-and-style.sh` before committing
|
||||
- Use `shopdb.api` imports, not internal paths
|
||||
- Keep database table names lowercase concatenated
|
||||
- Reference ADRs by number when making architectural decisions
|
||||
- Verify bundle/deploy scripts work before pushing to branches
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Feature tests in `tests/test_plugins/test_{plugin}_{feature}.py`
|
||||
- Use fixtures from `conftest.py` (app, client, db_session)
|
||||
- Test migrations with `test_plugin_migrations.py`
|
||||
- Frontend specs alongside components: `*.spec.js`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Site data in code**: Use settings/seed, not hardcoded values
|
||||
- **Migration conflicts**: Keep plugin chains linear, coordinate with PLUGIN_TABLE_OWNERS
|
||||
- **Unicode**: ASCII only - check logs, comments, commit messages
|
||||
- **Import surface**: Use `shopdb.api`, not internal paths
|
||||
- **Device tokens**: Kiosks require device_token authentication (ADR-016)
|
||||
|
||||
## Work PC Role
|
||||
|
||||
This machine (work PC):
|
||||
- Has GitHub credentials (ge-aero/shopdb-flask)
|
||||
- Carries bundles between dev PC and GitHub
|
||||
- Deploys to prod and ops servers
|
||||
- Mirrors wilm branch from GitHub to gitea
|
||||
|
||||
For active development, use the dev PC with gitea.
|
||||
24
DEPLOY-NOW.bat
Normal file
24
DEPLOY-NOW.bat
Normal file
@@ -0,0 +1,24 @@
|
||||
@echo off
|
||||
REM ShopDB Quick Deploy - Double-click this file on the VM
|
||||
REM This will deploy ShopDB Docker in one click
|
||||
|
||||
echo ========================================
|
||||
echo ShopDB Docker Quick Deploy
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check if we're in the right directory
|
||||
if not exist "docker-compose.prebuilt.yml" (
|
||||
echo ERROR: Not in shopdb-flask directory
|
||||
echo Please run this from the repository root
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting deployment...
|
||||
echo.
|
||||
|
||||
REM Run PowerShell deployment script
|
||||
powershell.exe -ExecutionPolicy Bypass -File "%~dp0deploy-to-vm.ps1" -SiteName "shopdb-vm"
|
||||
|
||||
pause
|
||||
177
DEPLOY-TO-VM.md
Normal file
177
DEPLOY-TO-VM.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Deploy ShopDB to Hyper-V VM
|
||||
|
||||
Quick guide to deploy the Docker stack to a Windows 11 Hyper-V VM.
|
||||
|
||||
## Prerequisites on VM
|
||||
|
||||
1. **Docker Desktop** installed and running
|
||||
2. **Git Bash** or **WSL** installed (for bash commands)
|
||||
3. **PowerShell** (already on Windows 11)
|
||||
|
||||
## Step 1: Copy Files to VM
|
||||
|
||||
**Method A: Shared Folder**
|
||||
```powershell
|
||||
# On host PC, share the repo
|
||||
# Then on VM, copy from network share
|
||||
Copy-Item -Recurse \\host-pc\shopdb-flask C:\shopdb
|
||||
```
|
||||
|
||||
**Method B: Git Clone**
|
||||
```bash
|
||||
# On VM
|
||||
cd C:\
|
||||
git clone git@github.com:ge-aero/shopdb-flask.git
|
||||
cd shopdb-flask
|
||||
git checkout wj
|
||||
```
|
||||
|
||||
**Method C: Export/Import**
|
||||
```powershell
|
||||
# On host PC
|
||||
Compress-Archive -Path C:\Users\270015376\Documents\github\flask-shopdb -DestinationPath C:\shopdb-deploy.zip
|
||||
|
||||
# Copy shopdb-deploy.zip to VM, then on VM:
|
||||
Expand-Archive -Path C:\shopdb-deploy.zip -DestinationPath C:\shopdb
|
||||
```
|
||||
|
||||
## Step 2: Run Deployment Script
|
||||
|
||||
```powershell
|
||||
# On VM, open PowerShell as Administrator
|
||||
cd C:\shopdb
|
||||
|
||||
# Option 1: Quick deploy with defaults
|
||||
.\deploy-to-vm.ps1
|
||||
|
||||
# Option 2: Specify site name and password
|
||||
.\deploy-to-vm.ps1 -SiteName "west-jefferson" -AdminPassword "YourSecurePassword123!"
|
||||
|
||||
# Option 3: See all options
|
||||
.\deploy-to-vm.ps1 -Help
|
||||
```
|
||||
|
||||
## What the Script Does
|
||||
|
||||
1. [OK] Checks Docker Desktop is running
|
||||
2. [OK] Creates .env from template
|
||||
3. [OK] Builds frontend (if needed)
|
||||
4. [OK] Builds Docker images (2-3 minutes, offline packages)
|
||||
5. [OK] Starts all services (db + api + backup)
|
||||
6. [OK] Runs database migrations
|
||||
7. [OK] Sets admin password
|
||||
|
||||
## Manual Deployment (Alternative)
|
||||
|
||||
If you prefer to run commands manually:
|
||||
|
||||
```bash
|
||||
# 1. Create environment
|
||||
cp .env.docker-dev .env
|
||||
|
||||
# 2. Build frontend (if not already built)
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 3. Build and start Docker stack
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup build
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup up -d
|
||||
|
||||
# 4. Initialize database
|
||||
docker exec flask-shopdb-api-1 flask db upgrade
|
||||
|
||||
# 5. Reset admin password (optional)
|
||||
docker exec flask-shopdb-db-1 mysql -uroot -proot-dev-password-not-for-production shopdb_flask -e \
|
||||
"UPDATE users SET passwordhash='scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547', failedlogins=0, lockeduntil=NULL WHERE username='admin';"
|
||||
# (Password is: admin)
|
||||
```
|
||||
|
||||
## Access
|
||||
|
||||
**URL:** https://localhost:5001 (or https://vm-ip-address:5001 from host)
|
||||
|
||||
**Login:**
|
||||
- Username: `admin`
|
||||
- Password: Whatever you set in deploy script (default: `ChangeMe123!`)
|
||||
|
||||
## Verify Deployment
|
||||
|
||||
```powershell
|
||||
# Check all services are running
|
||||
docker ps
|
||||
|
||||
# Should see:
|
||||
# - flask-shopdb-db-1
|
||||
# - flask-shopdb-api-1
|
||||
# - flask-shopdb-backup-store-1
|
||||
# - flask-shopdb-backup-watcher-1
|
||||
|
||||
# View logs
|
||||
docker logs -f flask-shopdb-api-1
|
||||
|
||||
# Test backup
|
||||
# Go to https://localhost:5001/settings/shopdbbackups
|
||||
# Click "Back Up Now"
|
||||
# Wait 30 seconds, should show as "Checked"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Docker not running:**
|
||||
```powershell
|
||||
# Start Docker Desktop manually
|
||||
& "C:\Program Files\Docker\Docker\Docker Desktop.exe"
|
||||
Start-Sleep -Seconds 30
|
||||
```
|
||||
|
||||
**Port 5001 already in use:**
|
||||
```powershell
|
||||
# Edit .env and change API_PORT
|
||||
# API_PORT=5002
|
||||
```
|
||||
|
||||
**Can't access from host PC:**
|
||||
```powershell
|
||||
# On VM, check Windows Firewall
|
||||
New-NetFirewallRule -DisplayName "ShopDB" -Direction Inbound -LocalPort 5001 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
**SSL certificate warnings:**
|
||||
- Normal for self-signed certs
|
||||
- Click "Advanced" -> "Proceed to localhost (unsafe)"
|
||||
|
||||
## Stop/Start
|
||||
|
||||
```bash
|
||||
# Stop
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml down
|
||||
|
||||
# Start (after first deployment)
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup up -d
|
||||
```
|
||||
|
||||
## Update After Code Changes
|
||||
|
||||
```bash
|
||||
# 1. Pull latest code
|
||||
git pull origin wj
|
||||
|
||||
# 2. Rebuild frontend (if changed)
|
||||
cd frontend && npm run build && cd ..
|
||||
|
||||
# 3. Rebuild and restart
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml build
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml up -d
|
||||
```
|
||||
|
||||
## Offline Deployment Notes
|
||||
|
||||
**This deployment works completely offline** because:
|
||||
- [OK] Frontend pre-built and checked into repo
|
||||
- [OK] Restic binary pre-downloaded (8.6MB in repo)
|
||||
- [OK] No npm/pip downloads during Docker build
|
||||
- [OK] All dependencies baked into images
|
||||
|
||||
Perfect for air-gapped or restricted network environments!
|
||||
235
DOCKER-DEPLOYMENT-COMPLETE.md
Normal file
235
DOCKER-DEPLOYMENT-COMPLETE.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Docker Deployment - Complete and Working
|
||||
|
||||
**Date:** 2026-09-07
|
||||
**Branch:** wj (post-wilm merge)
|
||||
**Status:** [OK] Core system operational, backup watcher pending
|
||||
|
||||
## What's Running
|
||||
|
||||
```
|
||||
CONTAINER STATUS PORT
|
||||
flask-shopdb-db-1 Up (healthy) 3306/tcp (internal)
|
||||
flask-shopdb-api-1 Up https://localhost:5001
|
||||
```
|
||||
|
||||
**Gunicorn Workers:** 4 workers with gevent (PIDs 74-77)
|
||||
**Plugins Enabled:** 12 of 17 installed
|
||||
**Database:** MySQL 8.4 with all migrations applied
|
||||
|
||||
## Access
|
||||
|
||||
**URL:** https://localhost:5001
|
||||
**Expected:** Browser warning for self-signed certificate (click through)
|
||||
**Login:** admin / (needs password reset - see below)
|
||||
|
||||
## How to Use
|
||||
|
||||
### Start Stack
|
||||
```bash
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
### Stop Stack
|
||||
```bash
|
||||
docker compose -f docker-compose.prebuilt.yml down
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker logs -f flask-shopdb-api-1
|
||||
docker logs flask-shopdb-db-1
|
||||
```
|
||||
|
||||
### Reset Admin Password
|
||||
```bash
|
||||
# Generate hash
|
||||
docker exec flask-shopdb-api-1 python -c \
|
||||
"from werkzeug.security import generate_password_hash; print(generate_password_hash('your-password'))"
|
||||
|
||||
# Update database (replace HASH with output above)
|
||||
docker exec flask-shopdb-db-1 mysql -uroot -p$MYSQL_ROOT_PASSWORD shopdb_flask -e \
|
||||
"UPDATE users SET passwordhash='HASH', failedlogins=0, lockeduntil=NULL WHERE username='admin';"
|
||||
```
|
||||
|
||||
Quick reset to password "admin":
|
||||
```bash
|
||||
docker exec flask-shopdb-db-1 mysql -uroot -proot-dev-password-not-for-production shopdb_flask -e \
|
||||
"UPDATE users SET
|
||||
passwordhash='scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547',
|
||||
failedlogins=0, lockeduntil=NULL
|
||||
WHERE username='admin';"
|
||||
```
|
||||
|
||||
### Rebuild After Code Changes
|
||||
```bash
|
||||
# Rebuild frontend (if frontend code changed)
|
||||
cd frontend
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Rebuild Docker image
|
||||
docker compose -f docker-compose.prebuilt.yml build api
|
||||
|
||||
# Restart
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
### Run Database Migrations
|
||||
```bash
|
||||
docker exec flask-shopdb-api-1 flask db upgrade
|
||||
```
|
||||
|
||||
### Plugin Management
|
||||
```bash
|
||||
# List plugins
|
||||
docker exec flask-shopdb-api-1 flask plugin list
|
||||
|
||||
# Enable plugin
|
||||
docker exec flask-shopdb-api-1 flask plugin enable PLUGIN_NAME
|
||||
|
||||
# Disable plugin
|
||||
docker exec flask-shopdb-api-1 flask plugin disable PLUGIN_NAME
|
||||
```
|
||||
|
||||
## Build Process Used
|
||||
|
||||
### Why Pre-Built Frontend?
|
||||
|
||||
**Problem:** Zscaler corporate proxy causes npm to fail inside Docker builds
|
||||
|
||||
**Solution:** Build frontend on host, copy into Docker image
|
||||
|
||||
### Build Steps Taken
|
||||
|
||||
1. **Build Frontend on Host**
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
# Creates frontend/dist/
|
||||
```
|
||||
|
||||
2. **Build Docker Image (Simple)**
|
||||
```bash
|
||||
# Uses Dockerfile.prebuilt (Python only, no npm)
|
||||
docker compose -f docker-compose.prebuilt.yml build
|
||||
```
|
||||
|
||||
3. **Start Services**
|
||||
```bash
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
4. **Auto-Bootstrap**
|
||||
- Container entrypoint runs migrations
|
||||
- Detects first deployment (no instance/plugins.json)
|
||||
- Auto-installs all 17 plugins (disabled by default)
|
||||
- Starts gunicorn
|
||||
|
||||
## Architecture
|
||||
|
||||
### Volumes
|
||||
- `flask-shopdb_db_data` - MySQL database (persistent)
|
||||
- `flask-shopdb_instance_data` - Plugin state, uploads, branding (persistent)
|
||||
|
||||
### Networks
|
||||
- `flask-shopdb_default` - Internal bridge network
|
||||
|
||||
### Image
|
||||
- **Built:** 2026-09-07 00:26:27 EDT
|
||||
- **Size:** 914MB
|
||||
- **Base:** python:3.14-slim
|
||||
- **Frontend:** Pre-built Vue 3 SPA (from host)
|
||||
|
||||
## What's Not Working Yet
|
||||
|
||||
### Backup Watcher Service
|
||||
|
||||
**Status:** [FAIL] Build failed
|
||||
**Reason:** Oracle Linux base image can't download packages due to SSL cert issues with Zscaler
|
||||
**Impact:** Can't auto-execute backups on schedule (manual backups still work via one-shot container)
|
||||
|
||||
**Error:**
|
||||
```
|
||||
Cannot download repomd.xml: Curl error (60): SSL peer certificate or SSH remote key was not OK
|
||||
```
|
||||
|
||||
**Workaround Attempted:**
|
||||
- Copied Zscaler cert to scripts/backup/zscaler-root-ca.pem
|
||||
- Dockerfile.backup copies cert and runs update-ca-trust
|
||||
- Still fails because microdnf runs BEFORE cert is trusted
|
||||
|
||||
**Fix Needed:**
|
||||
- Pre-download restic binary and bake into image OR
|
||||
- Use different base image (Alpine/Debian instead of Oracle Linux) OR
|
||||
- Skip backup watcher entirely, use cron on host to trigger one-shot backup container
|
||||
|
||||
## Production Readiness
|
||||
|
||||
**Core Application:** [OK] Production ready
|
||||
**Deployment Process:** [WARN] Requires npm on host + Docker build
|
||||
**Backup System:** [FAIL] Not complete (watcher service build fails)
|
||||
|
||||
### Recommended Next Steps
|
||||
|
||||
1. **CI/CD Pipeline** - Build images automatically, push to registry
|
||||
2. **Pre-Built Images** - Sites pull instead of building (2 min deploy)
|
||||
3. **Backup Watcher Fix** - Switch to Alpine base or pre-download restic
|
||||
4. **TLS Certificates** - Use proper certs from CA (not self-signed)
|
||||
5. **Secrets Management** - Externalize passwords (.env with strong values)
|
||||
|
||||
## Files Created
|
||||
|
||||
- `Dockerfile.prebuilt` - Simplified Docker build (no npm)
|
||||
- `docker-compose.prebuilt.yml` - Docker Compose for pre-built approach
|
||||
- `.dockerignore.prebuilt` - Allows frontend/dist to be copied
|
||||
- `start-docker-quick.sh` - Quick restart script
|
||||
- `DOCKER-PREBUILT-APPROACH.md` - Documents workaround
|
||||
- `PRODUCTION-READINESS.md` - Honest assessment
|
||||
|
||||
## Comparison to Previous Attempts
|
||||
|
||||
| Approach | Result | Time | Issue |
|
||||
|----------|--------|------|-------|
|
||||
| Multi-stage Dockerfile | [FAIL] Failed | 15+ min | npm fails with Zscaler |
|
||||
| Pre-built frontend | [OK] Success | 3 min | Works! |
|
||||
| Backup containers | [FAIL] Failed | N/A | Oracle Linux SSL issues |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Test frontend
|
||||
curl -k https://localhost:5001/
|
||||
# Should return: <!DOCTYPE html>
|
||||
|
||||
# Test API
|
||||
curl -k https://localhost:5001/api/settings
|
||||
# Should return: {"data":[...]}
|
||||
|
||||
# Test database
|
||||
docker exec flask-shopdb-db-1 mysql -uroot -p$MYSQL_ROOT_PASSWORD \
|
||||
-e "SELECT COUNT(*) FROM shopdb_flask.users;"
|
||||
# Should return: count of users
|
||||
|
||||
# Check workers
|
||||
docker logs flask-shopdb-api-1 2>&1 | grep "Booting worker"
|
||||
# Should show: 4 workers with PIDs
|
||||
|
||||
# Check plugins
|
||||
docker exec flask-shopdb-api-1 flask plugin list | grep Enabled | wc -l
|
||||
# Should return: 12
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
ShopDB Docker deployment is **operational** for core functionality:
|
||||
- [OK] Database running
|
||||
- [OK] API serving requests
|
||||
- [OK] Frontend built and accessible
|
||||
- [OK] Plugins installed and working
|
||||
- [OK] HTTPS configured
|
||||
- [WARN] Backup watcher needs work (but manual backups functional)
|
||||
|
||||
The pre-built frontend approach successfully bypasses Zscaler corporate proxy issues. Build time is 2-3 minutes (vs 15+ for failed multi-stage approach).
|
||||
|
||||
For production deployment, recommend setting up CI/CD to build images automatically and push to registry so sites can pull pre-built images instead of building locally.
|
||||
96
DOCKER-PREBUILT-APPROACH.md
Normal file
96
DOCKER-PREBUILT-APPROACH.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Docker Pre-Built Frontend Approach
|
||||
|
||||
**Problem:** Zscaler corporate proxy causes npm to fail inside Docker builds with "Exit handler never called" error.
|
||||
|
||||
**Solution:** Build frontend on host (where npm works), copy pre-built dist/ into Docker image.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Build frontend on host
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# 2. Build Docker image (uses pre-built dist/)
|
||||
cd ..
|
||||
docker compose -f docker-compose.prebuilt.yml build
|
||||
|
||||
# 3. Start services
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
|
||||
# 4. Initialize (first time only)
|
||||
docker compose -f docker-compose.prebuilt.yml exec api flask db upgrade
|
||||
docker compose -f docker-compose.prebuilt.yml exec api bash /app/scripts/bootstrap-plugins.sh
|
||||
|
||||
# 5. Access
|
||||
https://localhost:5001
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `Dockerfile.prebuilt` - Simplified Dockerfile without npm build
|
||||
- `docker-compose.prebuilt.yml` - Uses Dockerfile.prebuilt
|
||||
- `.dockerignore.prebuilt` - Allows frontend/dist to be copied
|
||||
|
||||
## Why This Works
|
||||
|
||||
**Normal Dockerfile:**
|
||||
- Multi-stage build
|
||||
- Stage 1: Node 20 runs `npm ci` and `npm run build`
|
||||
- Stage 2: Copies built frontend from stage 1
|
||||
- **PROBLEM:** npm fails inside Docker with Zscaler proxy
|
||||
|
||||
**Pre-Built Approach:**
|
||||
- Build frontend on Windows host (npm works here)
|
||||
- Single-stage Dockerfile copies pre-built `frontend/dist/`
|
||||
- Only installs Python dependencies
|
||||
- **FASTER:** 2-3 minutes vs 15+ minutes
|
||||
|
||||
## Trade-offs
|
||||
|
||||
**Pros:**
|
||||
- Avoids corporate proxy issues
|
||||
- Much faster builds (no npm in container)
|
||||
- Frontend build errors visible immediately
|
||||
- Can iterate on frontend without rebuilding container
|
||||
|
||||
**Cons:**
|
||||
- Requires npm on host machine
|
||||
- Two-step build process
|
||||
- frontend/dist must be in version control OR built before deployment
|
||||
|
||||
## For Production (CI/CD)
|
||||
|
||||
```yaml
|
||||
# GitHub Actions / Jenkins example
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -f Dockerfile.prebuilt -t shopdb:${{ github.sha }} .
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
docker push shopdb:${{ github.sha }}
|
||||
```
|
||||
|
||||
## Comparison
|
||||
|
||||
| Approach | Build Time | Corporate Proxy | Complexity | Production Ready |
|
||||
|----------|-----------|----------------|------------|------------------|
|
||||
| Multi-stage Dockerfile | 15+ min | [FAIL] Fails | High | [FAIL] No |
|
||||
| Pre-built frontend | 2-3 min | [OK] Works | Medium | [WARN] Yes (with CI/CD) |
|
||||
| Pre-built images from registry | 30 sec | [OK] Works | Low | [OK] Yes |
|
||||
|
||||
## Next Steps
|
||||
|
||||
For true production deployment:
|
||||
1. Set up CI/CD to build frontend + Docker image
|
||||
2. Push to container registry
|
||||
3. Sites pull pre-built images
|
||||
4. No build step at deployment site
|
||||
398
DOCKER-SETUP.md
Normal file
398
DOCKER-SETUP.md
Normal file
@@ -0,0 +1,398 @@
|
||||
# ShopDB Docker Setup
|
||||
|
||||
Complete Docker deployment for development and testing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- At least 8GB RAM allocated to Docker
|
||||
- Zscaler corporate certificate extracted (if behind corporate proxy)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
cd C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb
|
||||
|
||||
# 2. Extract Zscaler certificate (if needed)
|
||||
powershell.exe -File C:/temp/extract-ca.ps1
|
||||
# This creates corp-root-ca.pem in repo root
|
||||
|
||||
# 3. Build images
|
||||
docker compose build
|
||||
|
||||
# 4. Start stack
|
||||
docker compose up -d
|
||||
|
||||
# 5. Initialize database (first time only)
|
||||
docker compose exec api flask db upgrade
|
||||
|
||||
# 6. Install plugins (first time only)
|
||||
docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json
|
||||
|
||||
# 7. Access ShopDB
|
||||
https://localhost:5001
|
||||
```
|
||||
|
||||
Login: `admin` / (reset password - see below)
|
||||
|
||||
## Architecture
|
||||
|
||||
The Docker stack includes:
|
||||
|
||||
1. **MySQL Database** (`db` service)
|
||||
- Port: 3306 (internal), mapped to host based on `.env`
|
||||
- Volume: `db_data` for persistence
|
||||
- Health checks enabled
|
||||
|
||||
2. **Flask API** (`api` service)
|
||||
- Port: 5001 (HTTPS with self-signed cert)
|
||||
- Built frontend served from `/app/frontend/dist`
|
||||
- Gunicorn with gevent workers (4 workers, 2000 connections each)
|
||||
- Volumes:
|
||||
- `instance_data` - Plugin state, uploads, branding
|
||||
- `./plugins` - Plugin code (read-only)
|
||||
- `./scripts` - Deployment scripts (read-only)
|
||||
- `./certs` - TLS certificates (read-only)
|
||||
|
||||
3. **Backup Service** (optional, via `docker-compose.backup.yml`)
|
||||
- One-shot container for backups
|
||||
- Triggered manually or via scheduler
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### 1. Extract Zscaler Certificate (Corporate Proxy)
|
||||
|
||||
Save this as `C:/temp/extract-ca.ps1`:
|
||||
|
||||
```powershell
|
||||
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
|
||||
$request.Method = 'HEAD'
|
||||
$request.Timeout = 10000
|
||||
$response = $request.GetResponse()
|
||||
$root = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($request.ServicePoint.Certificate)
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
|
||||
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
|
||||
[void]$builder.AppendLine('-----END CERTIFICATE-----')
|
||||
$certPath = 'C:\Users\270015376\Documents\github\flask-shopdb\corp-root-ca.pem'
|
||||
Set-Content -Path $certPath -Value $builder.ToString() -Encoding ASCII
|
||||
Write-Host "Staged corp-root-ca.pem"
|
||||
```
|
||||
|
||||
Run: `powershell.exe -ExecutionPolicy Bypass -File C:/temp/extract-ca.ps1`
|
||||
|
||||
### 2. Generate TLS Certificates
|
||||
|
||||
```bash
|
||||
cd certs
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout server.key -out server.crt -days 365 \
|
||||
-subj "//CN=localhost"
|
||||
```
|
||||
|
||||
Or use the provided script:
|
||||
```bash
|
||||
./scripts/generate-dev-certs.sh
|
||||
```
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Copy and edit `.env`:
|
||||
```bash
|
||||
cp .env.docker-dev .env
|
||||
# Edit if needed - defaults work for local dev
|
||||
```
|
||||
|
||||
### 4. Build Images
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
**Note:** First build takes 10-15 minutes due to:
|
||||
- npm install (~5 min with Zscaler proxy)
|
||||
- Frontend Vite build (~2 min)
|
||||
- Python dependencies (~3 min)
|
||||
|
||||
Subsequent builds use Docker layer cache and are much faster.
|
||||
|
||||
### 5. Start Services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check status:
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
### 6. Initialize Database
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker compose exec api flask db upgrade
|
||||
|
||||
# Install and enable all plugins
|
||||
docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json
|
||||
```
|
||||
|
||||
The `apply-profile` command:
|
||||
- Installs all 17 plugins
|
||||
- Registers them in `instance/plugins.json`
|
||||
- Leaves them disabled by default
|
||||
- Enables them via Settings UI as needed
|
||||
|
||||
### 7. Reset Admin Password
|
||||
|
||||
```bash
|
||||
# Generate hash for "admin" password
|
||||
docker compose exec api python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('admin'))"
|
||||
|
||||
# Update database
|
||||
docker compose exec db mysql -u root -p shopdb_flask -e "
|
||||
UPDATE users
|
||||
SET passwordhash = 'PASTE_HASH_HERE',
|
||||
failedlogins = 0,
|
||||
lockeduntil = NULL
|
||||
WHERE username = 'admin';"
|
||||
```
|
||||
|
||||
Or use quick method (sets password to `admin`):
|
||||
```bash
|
||||
docker compose exec db mysql -u root -p shopdb_flask -e "
|
||||
UPDATE users SET
|
||||
passwordhash = 'scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547',
|
||||
failedlogins = 0,
|
||||
lockeduntil = NULL
|
||||
WHERE username = 'admin';"
|
||||
```
|
||||
|
||||
## Import Production Database
|
||||
|
||||
```bash
|
||||
# Copy SQL dump to container
|
||||
docker cp latest.sql shopdb-api-1:/tmp/
|
||||
|
||||
# Import
|
||||
docker compose exec db mysql -u root -p shopdb_flask < /tmp/latest.sql
|
||||
|
||||
# Run migrations to bring schema up to date
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask plugin upgrade-all
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
docker compose logs -f db
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
```bash
|
||||
docker compose restart api
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
### Rebuild After Code Changes
|
||||
```bash
|
||||
docker compose build api
|
||||
docker compose up -d api
|
||||
```
|
||||
|
||||
### Access Container Shell
|
||||
```bash
|
||||
docker compose exec api bash
|
||||
docker compose exec db bash
|
||||
```
|
||||
|
||||
### Run Flask Commands
|
||||
```bash
|
||||
docker compose exec api flask plugin list
|
||||
docker compose exec api flask db current
|
||||
docker compose exec api flask routes
|
||||
```
|
||||
|
||||
### Backup & Restore
|
||||
```bash
|
||||
# Trigger backup
|
||||
docker compose -f docker-compose.backup.yml up backup
|
||||
|
||||
# View backups
|
||||
docker compose exec api flask plugin list
|
||||
# (Accessible via Settings > Backups in UI)
|
||||
|
||||
# Restore (via UI at Settings > Backups > Select backup > Restore)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "npm ci failed" during build
|
||||
|
||||
**Cause:** Zscaler proxy blocking npm registry.
|
||||
|
||||
**Fix:**
|
||||
1. Extract Zscaler certificate (see above)
|
||||
2. Ensure `corp-root-ca.pem` exists in repo root
|
||||
3. Rebuild: `docker compose build --no-cache`
|
||||
|
||||
### "database is unhealthy"
|
||||
|
||||
**Cause:** MySQL container not ready or port conflict.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs db
|
||||
|
||||
# Check if port 3306 is already in use
|
||||
netstat -an | grep 3306
|
||||
|
||||
# If conflict, edit .env and set MYSQL_PORT=3307
|
||||
```
|
||||
|
||||
### "Access denied for user 'shopdb'"
|
||||
|
||||
**Cause:** Database credentials mismatch.
|
||||
|
||||
**Fix:** Check `.env` file - `MYSQL_PASSWORD` must match `DATABASE_URL`.
|
||||
|
||||
### "Could not connect to database"
|
||||
|
||||
**Cause:** Database service not started or wrong hostname.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check db is running
|
||||
docker compose ps
|
||||
|
||||
# API must use hostname 'db' not 'localhost'
|
||||
# In docker-compose.yml: DATABASE_URL=...@db:3306/...
|
||||
```
|
||||
|
||||
### "ERR_CERT_AUTHORITY_INVALID" in browser
|
||||
|
||||
**Expected:** Self-signed certificate warning.
|
||||
|
||||
**Fix:** Click "Advanced" > "Proceed to localhost (unsafe)" - this is normal for dev.
|
||||
|
||||
Or generate proper cert:
|
||||
```bash
|
||||
# Add your machine's hostname to cert
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout certs/server.key -out certs/server.crt -days 365 \
|
||||
-subj "//CN=$(hostname)" \
|
||||
-addext "subjectAltName=DNS:localhost,DNS:$(hostname),IP:127.0.0.1"
|
||||
```
|
||||
|
||||
### "Plugin X is already installed" but not visible
|
||||
|
||||
**Cause:** Plugin installed but not enabled.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
docker compose exec api flask plugin enable X
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
### Build is very slow
|
||||
|
||||
**Expected:** First build with Zscaler proxy takes 10-15 minutes.
|
||||
|
||||
**Speed up:**
|
||||
- Subsequent builds use cache (much faster)
|
||||
- If rebuilding often, comment out `--no-cache` flag
|
||||
- Build frontend separately on host, copy dist/ into image
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
For production:
|
||||
|
||||
1. **Change all secrets** in `.env`:
|
||||
- `SECRET_KEY`
|
||||
- `JWT_SECRET_KEY`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
- `MYSQL_PASSWORD`
|
||||
|
||||
2. **Use proper TLS certificates** (not self-signed):
|
||||
- Place in `./certs/server.crt` and `./certs/server.key`
|
||||
- Or use reverse proxy (nginx/IIS) for TLS termination
|
||||
|
||||
3. **Set `FLASK_DEBUG=0`** in production
|
||||
|
||||
4. **Configure backups**:
|
||||
- Set up cron job: `0 2 * * * cd /path/to/shopdb && docker compose -f docker-compose.backup.yml up backup`
|
||||
- Configure SMB or S3 destination in Settings > Backups
|
||||
|
||||
5. **Set `SHOPDB_ENV_LABEL`** to site name (e.g., "WEST-JEFFERSON")
|
||||
|
||||
6. **Review `docker-compose.yml`**:
|
||||
- Adjust worker count based on load
|
||||
- Configure proper `restart` policy
|
||||
- Set resource limits if needed
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why HTTPS in Docker?
|
||||
|
||||
Secure context APIs (clipboard, geolocation) require HTTPS. Running over HTTP disables these browser features.
|
||||
|
||||
### Why Gevent Workers?
|
||||
|
||||
Endpoint agents long-poll `/api/endpoints/commands/next`. With threading, 200 agents = 200 threads = resource exhaustion. Gevent uses greenlets (cheap coroutines) so 2000+ connections per worker is feasible.
|
||||
|
||||
### Plugin Bootstrap
|
||||
|
||||
The entrypoint (`scripts/docker-entrypoint.sh`) automatically:
|
||||
1. Runs database migrations
|
||||
2. Checks if `instance/plugins.json` exists
|
||||
3. If not, installs all available plugins (disabled)
|
||||
4. Starts gunicorn
|
||||
|
||||
This ensures imported databases with plugin tables get matching plugin registry.
|
||||
|
||||
### Instance Data Persistence
|
||||
|
||||
The `instance_data` volume persists:
|
||||
- `plugins.json` - Which plugins are enabled
|
||||
- Uploaded files (floor plans, branding, photos, warranties, slides)
|
||||
- Dell OAuth tokens
|
||||
- Any plugin-specific state
|
||||
|
||||
**Important:** `docker compose down -v` deletes volumes - use plain `docker compose down` to preserve data.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
flask-shopdb/
|
||||
|-- docker-compose.yml # Main stack definition
|
||||
|-- docker-compose.backup.yml # Backup service
|
||||
|-- Dockerfile # Multi-stage build
|
||||
|-- .env # Environment config (gitignored)
|
||||
|-- corp-root-ca.pem # Zscaler cert (gitignored)
|
||||
|-- certs/ # TLS certificates (gitignored)
|
||||
| |-- server.crt
|
||||
| +-- server.key
|
||||
|-- scripts/
|
||||
| |-- docker-entrypoint.sh # Container startup
|
||||
| +-- bootstrap-plugins.sh # Plugin auto-install
|
||||
+-- deploy/
|
||||
+-- site-profile-universal.json # Full plugin list
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After setup:
|
||||
|
||||
1. Browse to https://localhost:5001
|
||||
2. Login as `admin`
|
||||
3. Go to Settings > Plugins
|
||||
4. Enable plugins your site needs
|
||||
5. Configure settings (CORS, backup destination, etc.)
|
||||
6. Import data or start fresh
|
||||
|
||||
For local development with hot-reload, see `LOCAL-DEV-SETUP.md`.
|
||||
@@ -123,6 +123,9 @@ COPY migrations/ ./migrations/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY wsgi.py ./
|
||||
|
||||
# Make entrypoint and bootstrap scripts executable
|
||||
RUN chmod +x /app/scripts/docker-entrypoint.sh /app/scripts/bootstrap-plugins.sh
|
||||
|
||||
# Built SPA from stage 1. Flask serves it via register_frontend_routes.
|
||||
COPY --from=frontendbuild /build/frontend/dist ./frontend/dist
|
||||
|
||||
@@ -139,6 +142,9 @@ USER shopdb
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# Entrypoint runs migrations and bootstraps plugins on first deployment
|
||||
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||
|
||||
# Probe the public health route, not the login endpoint. Probing POST
|
||||
# /api/auth/login logged a 400 every 30s and ran the credential path from a
|
||||
# health check. The route answers 200 even when the database is degraded, so the
|
||||
|
||||
47
Dockerfile.prebuilt
Normal file
47
Dockerfile.prebuilt
Normal file
@@ -0,0 +1,47 @@
|
||||
# Dockerfile for pre-built frontend
|
||||
# Use this when frontend/dist is already built on the host
|
||||
# Avoids npm issues with corporate proxy
|
||||
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& pip install --no-cache-dir gunicorn gevent
|
||||
|
||||
# Copy application code
|
||||
COPY shopdb/ ./shopdb/
|
||||
COPY plugins/ ./plugins/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY wsgi.py ./
|
||||
|
||||
# Copy pre-built frontend
|
||||
COPY frontend/dist ./frontend/dist
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x /app/scripts/docker-entrypoint.sh /app/scripts/bootstrap-plugins.sh
|
||||
|
||||
# Create instance directory
|
||||
RUN mkdir -p /app/instance
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Set environment
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_APP=shopdb
|
||||
|
||||
# Entrypoint runs migrations and starts gunicorn
|
||||
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||
362
LOCAL-DEV-SETUP.md
Normal file
362
LOCAL-DEV-SETUP.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# ShopDB Local Development Setup (Windows)
|
||||
|
||||
Complete guide for setting up ShopDB Flask development environment on Windows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.13+
|
||||
- Docker Desktop (for MySQL and testing)
|
||||
- Git
|
||||
- Node.js 20+ (for frontend development)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone and enter repo
|
||||
cd C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb
|
||||
|
||||
# 2. Create Python virtual environment
|
||||
python -m venv venv
|
||||
./venv/Scripts/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. Start MySQL in Docker
|
||||
docker compose up -d db
|
||||
|
||||
# 4. Configure environment
|
||||
cp .env.docker .env
|
||||
# Edit .env and set DATABASE_URL (see below)
|
||||
|
||||
# 5. Install and enable plugins
|
||||
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
|
||||
./venv/Scripts/flask.exe plugin install backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty
|
||||
./venv/Scripts/flask.exe plugin enable <each-plugin-name>
|
||||
|
||||
# 6. Start Flask
|
||||
./venv/Scripts/python.exe run_dev.py
|
||||
```
|
||||
|
||||
Visit http://localhost:5001 and login with `admin` / `admin`.
|
||||
|
||||
## Detailed Setup
|
||||
|
||||
### 1. Database Setup
|
||||
|
||||
MySQL runs in Docker on **port 3307** (not 3306) to avoid conflicts with any local MySQL instance.
|
||||
|
||||
```bash
|
||||
# Start MySQL container
|
||||
docker compose up -d db
|
||||
|
||||
# Verify it's running
|
||||
docker ps --filter name=shopdb-dev-db
|
||||
# Should show: 127.0.0.1:3307->3306/tcp
|
||||
|
||||
# Test connection
|
||||
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' -e "SELECT 1;"
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
Create `.env` file with:
|
||||
|
||||
```bash
|
||||
# Database (port 3307 because Docker maps it there)
|
||||
DATABASE_URL=mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev
|
||||
|
||||
# Flask
|
||||
FLASK_ENV=development
|
||||
FLASK_DEBUG=1
|
||||
|
||||
# Secrets (dev only)
|
||||
SECRET_KEY=docker-dev-secret-key-change-for-production
|
||||
JWT_SECRET_KEY=docker-dev-jwt-secret-change-for-production
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5001
|
||||
|
||||
# API Port
|
||||
API_PORT=5001
|
||||
|
||||
# ShopDB Label
|
||||
SHOPDB_ENV_LABEL=LOCAL-DEV
|
||||
|
||||
# Log Level
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# Backup Storage (for local dev)
|
||||
SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups
|
||||
```
|
||||
|
||||
**Important:** Change `YOUR_USERNAME` to your actual Windows username.
|
||||
|
||||
### 3. Import Database (Optional)
|
||||
|
||||
If you have a production database dump:
|
||||
|
||||
```bash
|
||||
# Import latest.sql
|
||||
docker exec -i shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev < latest.sql
|
||||
|
||||
# Run migrations
|
||||
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
|
||||
./venv/Scripts/flask.exe db upgrade
|
||||
```
|
||||
|
||||
### 4. Plugin Installation
|
||||
|
||||
All 17 plugins must be installed before they can be enabled:
|
||||
|
||||
```bash
|
||||
# Set DATABASE_URL for CLI commands
|
||||
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
|
||||
|
||||
# Install all plugins
|
||||
for plugin in backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty; do
|
||||
./venv/Scripts/flask.exe plugin install $plugin
|
||||
done
|
||||
|
||||
# Enable all plugins
|
||||
for plugin in backups computers employees endpoints geenforce guides knowledgebase machines measuringtools network notifications printedparts printers slides tools usb warranty; do
|
||||
./venv/Scripts/flask.exe plugin enable $plugin
|
||||
done
|
||||
```
|
||||
|
||||
**Why this is necessary:**
|
||||
- Plugin state is tracked in `instance/plugins.json`, not the database
|
||||
- When you import a prod database, it has plugin tables but no plugin registry
|
||||
- Installing registers plugins in the JSON file
|
||||
- Enabling makes them active (but requires app restart)
|
||||
|
||||
### 5. Reset Admin Password
|
||||
|
||||
If you imported a prod database and don't know the password:
|
||||
|
||||
```bash
|
||||
# Generate new password hash
|
||||
./venv/Scripts/python.exe -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('admin'))"
|
||||
|
||||
# Reset via MySQL
|
||||
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev -e "
|
||||
UPDATE users
|
||||
SET passwordhash = 'PASTE_HASH_HERE',
|
||||
failedlogins = 0,
|
||||
lockeduntil = NULL
|
||||
WHERE username = 'admin';
|
||||
"
|
||||
```
|
||||
|
||||
Or use the simpler method:
|
||||
```bash
|
||||
docker exec shopdb-dev-db mysql -u root -p'root-dev-password-not-for-production' shopdb_flask_dev -e "
|
||||
UPDATE users
|
||||
SET passwordhash = 'scrypt:32768:8:1\$B4LhpPrM6qxijInK\$b99b47a0bb5fd45761a1823f401f70a3ceccf71a5d38dc433378aa07426ef8eaf7e679ff3dd7b0ce9772600f83368ac642e9419b9beaa49f87483318bae34547',
|
||||
failedlogins = 0,
|
||||
lockeduntil = NULL
|
||||
WHERE username = 'admin';
|
||||
"
|
||||
```
|
||||
|
||||
Password is now: `admin` / `admin`
|
||||
|
||||
### 6. Starting Flask
|
||||
|
||||
**Always use `run_dev.py`** (not `flask run` directly):
|
||||
|
||||
```bash
|
||||
./venv/Scripts/python.exe run_dev.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Loads `.env` file with DATABASE_URL
|
||||
- Sets all environment variables
|
||||
- Starts Flask without auto-reloader (prevents env var loss)
|
||||
- Binds to 0.0.0.0:5001
|
||||
|
||||
**Do NOT use:**
|
||||
- `flask run` - Won't load `.env` properly
|
||||
- `flask run --reload` - Loses environment variables
|
||||
|
||||
### 7. Frontend Development (Optional)
|
||||
|
||||
To develop the Vue frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend dev server runs on http://localhost:5173 and proxies API requests to Flask on port 5001.
|
||||
|
||||
For production build (served by Flask):
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
# Outputs to frontend/dist/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Can't connect to MySQL on 127.0.0.1:3306"
|
||||
|
||||
**Cause:** Flask is trying port 3306 instead of 3307.
|
||||
|
||||
**Fix:**
|
||||
1. Make sure `.env` has `DATABASE_URL=...@localhost:3307/...`
|
||||
2. Use `run_dev.py` to start Flask (NOT `flask run`)
|
||||
3. If problem persists, restart Flask completely
|
||||
|
||||
### "Cannot import name '_backups_configured'"
|
||||
|
||||
**Cause:** Merge conflict between wj and wilm backup implementations.
|
||||
|
||||
**Fix:** This was fixed in commit `8a1fe0ada8` - pull latest wj branch.
|
||||
|
||||
### "Plugin X is not installed"
|
||||
|
||||
**Cause:** Plugin registry (`instance/plugins.json`) doesn't exist or is incomplete.
|
||||
|
||||
**Fix:** Run plugin installation commands (section 4 above).
|
||||
|
||||
### "Access is denied: '/backups'"
|
||||
|
||||
**Cause:** Backup system trying to use Linux path on Windows.
|
||||
|
||||
**Fix:**
|
||||
1. Add to `.env`: `SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups`
|
||||
2. Create directory: `mkdir backups`
|
||||
3. Restart Flask with `run_dev.py`
|
||||
|
||||
### Docker npm install fails (Zscaler proxy)
|
||||
|
||||
**Cause:** Corporate proxy intercepts HTTPS, npm doesn't trust certificate.
|
||||
|
||||
**Fix:** Extract Zscaler root CA certificate:
|
||||
|
||||
```powershell
|
||||
# Run this PowerShell script
|
||||
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
|
||||
$request.Method = 'HEAD'
|
||||
$request.Timeout = 10000
|
||||
$response = $request.GetResponse()
|
||||
$root = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($request.ServicePoint.Certificate)
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
|
||||
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
|
||||
[void]$builder.AppendLine('-----END CERTIFICATE-----')
|
||||
Set-Content -Path 'C:\Users\270015376\Documents\github\flask-shopdb\corp-root-ca.pem' -Value $builder.ToString() -Encoding ASCII
|
||||
```
|
||||
|
||||
Then rebuild Docker: `docker compose build --no-cache`
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
pytest
|
||||
pytest tests/test_specific.py
|
||||
pytest -v -s # Verbose with print output
|
||||
```
|
||||
|
||||
### Run Migrations
|
||||
```bash
|
||||
export DATABASE_URL="mysql+pymysql://root:root-dev-password-not-for-production@localhost:3307/shopdb_flask_dev"
|
||||
./venv/Scripts/flask.exe db upgrade
|
||||
```
|
||||
|
||||
### Create Migration
|
||||
```bash
|
||||
./venv/Scripts/flask.exe db migrate -m "Description of changes"
|
||||
```
|
||||
|
||||
### Check Plugin Status
|
||||
```bash
|
||||
./venv/Scripts/flask.exe plugin list
|
||||
```
|
||||
|
||||
### Enable/Disable Plugin
|
||||
```bash
|
||||
./venv/Scripts/flask.exe plugin enable slides
|
||||
./venv/Scripts/flask.exe plugin disable slides
|
||||
```
|
||||
|
||||
### Trigger Backup
|
||||
```bash
|
||||
# Via API (requires admin JWT token)
|
||||
curl -X POST http://localhost:5001/api/shopdbbackups \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Or via Docker
|
||||
docker compose -f docker-compose.backup.yml up backup
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why Port 3307?
|
||||
Docker MySQL maps internal port 3306 to host port 3307 to avoid conflicts with any local MySQL installation. This is configured in `docker-compose.yml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:3307:3306"
|
||||
```
|
||||
|
||||
### Why run_dev.py?
|
||||
Flask's `flask run` command uses an auto-reloader that spawns a subprocess. This subprocess loses environment variables loaded from `.env`. The `run_dev.py` wrapper:
|
||||
1. Loads `.env` via python-dotenv BEFORE importing Flask
|
||||
2. Uses `--no-reload` flag to prevent subprocess spawning
|
||||
3. Ensures DATABASE_URL and other vars persist
|
||||
|
||||
### Plugin Architecture
|
||||
- Plugins are Python packages in `plugins/` directory
|
||||
- Plugin state tracked in `instance/plugins.json` (not database)
|
||||
- Plugin tables tracked via Alembic migration chains
|
||||
- Each plugin has own migrations in `plugins/<name>/migrations/`
|
||||
|
||||
### Backup System
|
||||
- Designed for Docker compose deployment
|
||||
- API queues backups via sentinel file (`.backup-requested`)
|
||||
- Actual backup execution requires Docker sidecar container
|
||||
- On Windows dev, API can queue but won't execute without Docker
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
flask-shopdb/
|
||||
|-- .env # Local environment config (gitignored)
|
||||
|-- run_dev.py # Local development launcher
|
||||
|-- shopdb/ # Flask application
|
||||
| |-- __init__.py # App factory
|
||||
| |-- config.py # Configuration classes
|
||||
| |-- core/ # Core models and APIs
|
||||
| +-- plugins/ # Plugin manager
|
||||
|-- plugins/ # Plugin packages
|
||||
| |-- backups/
|
||||
| |-- computers/
|
||||
| +-- ...
|
||||
|-- frontend/ # Vue 3 SPA
|
||||
| |-- src/
|
||||
| +-- dist/ # Production build (gitignored)
|
||||
|-- instance/ # Runtime state (gitignored)
|
||||
| |-- plugins.json # Plugin registry
|
||||
| +-- ...
|
||||
|-- migrations/ # Core Alembic migrations
|
||||
|-- scripts/ # Deployment and backup scripts
|
||||
|-- backups/ # Local backup storage (gitignored)
|
||||
+-- tests/ # Test suite
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- `main` branch - Published/public version (GitHub)
|
||||
- `wj` branch - Production deployment (West Jefferson site)
|
||||
- `wilm` branch - Development branch (upstream from benjamin.baker)
|
||||
- `dev` branch - Ops deployment branch
|
||||
|
||||
**Never commit to `main` directly** - it's published to public wiki.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Backup Setup](scripts/backup/README.md)
|
||||
- [Plugin Development](docs/PLUGIN-HOOKS.md)
|
||||
- [ADRs](wiki/ADRs/)
|
||||
- [API Docs](http://localhost:5001/api/docs) (when Flask is running)
|
||||
345
PRODUCTION-READINESS.md
Normal file
345
PRODUCTION-READINESS.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# ShopDB Production Readiness Assessment
|
||||
|
||||
**Last Updated:** 2026-09-07
|
||||
**Branch:** wj (post-wilm merge)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
[CRITICAL] **NOT PRODUCTION READY** in current state without significant operational investment.
|
||||
|
||||
The Docker deployment requires extensive manual setup, multiple services, and assumes expertise with Docker Compose, restic backup systems, and Flask plugin architecture. This assessment documents current gaps and provides recommendations.
|
||||
|
||||
## Current State: Deployment Complexity
|
||||
|
||||
### What Works
|
||||
|
||||
[OK] **Flask application code** - Stable, tested, 17 plugins functional
|
||||
[OK] **Database schema** - Migrations work, data model is sound
|
||||
[OK] **Frontend build** - Vue 3 SPA compiles and serves correctly
|
||||
[OK] **Docker images** - Build successfully (with caveats below)
|
||||
[OK] **Plugin architecture** - Clean, documented, extensible
|
||||
|
||||
### Critical Gaps
|
||||
|
||||
[FAIL] **One-command deployment** - Requires 7-10 manual steps
|
||||
[FAIL] **Corporate proxy handling** - Zscaler cert extraction needed for npm
|
||||
[FAIL] **Backup system complexity** - 3 containers + restic + scheduler
|
||||
[FAIL] **TLS certificate generation** - Manual OpenSSL commands required
|
||||
[FAIL] **Plugin initialization** - Must run `apply-profile` after first start
|
||||
[FAIL] **Password reset** - No default admin password, requires manual SQL
|
||||
[FAIL] **Build time** - 15+ minutes with corporate proxy
|
||||
[FAIL] **Documentation scattered** - 3 different setup guides (Docker, Local, Backup)
|
||||
|
||||
## Why Is This So Complex?
|
||||
|
||||
### 1. Corporate Network Reality
|
||||
|
||||
**Problem:** Behind Zscaler proxy, npm can't trust npm registry
|
||||
**Impact:** Docker build fails without extracted corporate root CA
|
||||
**Workaround:** PowerShell script to extract cert before building
|
||||
**Production Fix:** Pre-build images in CI/CD with cert, publish to registry
|
||||
|
||||
### 2. Plugin Architecture Design
|
||||
|
||||
**Problem:** Plugin state in `instance/plugins.json`, not database
|
||||
**Impact:** Importing prod database doesn't register plugins
|
||||
**Workaround:** Manual `flask plugin apply-profile` after first start
|
||||
**Production Fix:** Auto-detect installed plugins from schema on boot
|
||||
|
||||
### 3. Backup System Architecture
|
||||
|
||||
**Problem:** Designed for restic with 3-container orchestration
|
||||
**Impact:** Requires backup-store + backup-watcher + backup containers
|
||||
**Workaround:** All documented, but complex to deploy
|
||||
**Production Fix:** Simpler mysqldump-only mode with direct file storage
|
||||
|
||||
### 4. Multi-Stage Docker Build
|
||||
|
||||
**Problem:** Frontend build needs Node 20, backend needs Python 3.14
|
||||
**Impact:** Multi-stage build is slow, complex, hard to debug
|
||||
**Workaround:** Build works, but takes 15 minutes
|
||||
**Production Fix:** Pre-build frontend on CI, copy dist/ into Python-only image
|
||||
|
||||
## Deployment Models (Ordered by Simplicity)
|
||||
|
||||
### Option 1: IIS + Windows Services (SIMPLEST)
|
||||
|
||||
**Best for:** Single Windows server deployment
|
||||
**Complexity:** Low
|
||||
**Maintenance:** Medium
|
||||
|
||||
**Setup:**
|
||||
1. Install Python 3.13+
|
||||
2. Install MySQL (or use existing instance)
|
||||
3. Run installer (creates Windows service for Flask)
|
||||
4. Configure IIS reverse proxy
|
||||
5. Install plugins via UI
|
||||
|
||||
**Pros:**
|
||||
- Native Windows deployment
|
||||
- Familiar IIS management
|
||||
- No Docker complexity
|
||||
- Fast startup
|
||||
|
||||
**Cons:**
|
||||
- Windows-only
|
||||
- Manual updates
|
||||
- No containerization benefits
|
||||
|
||||
**Production Ready:** [WARN] **YES** (with installer improvements)
|
||||
|
||||
### Option 2: Docker Pre-Built Images (RECOMMENDED)
|
||||
|
||||
**Best for:** Sites with Docker but no build capability
|
||||
**Complexity:** Low-Medium
|
||||
**Maintenance:** Low
|
||||
|
||||
**Setup:**
|
||||
1. Pull pre-built image from registry
|
||||
2. Run docker-compose with .env file
|
||||
3. Access UI, enable plugins
|
||||
|
||||
**Pros:**
|
||||
- No build step
|
||||
- Fast deployment (2 minutes)
|
||||
- Consistent environment
|
||||
- Easy updates (pull new image)
|
||||
|
||||
**Cons:**
|
||||
- Requires Docker
|
||||
- Need image registry
|
||||
- Initial registry setup
|
||||
|
||||
**Production Ready:** [OK] **YES** (requires CI/CD setup)
|
||||
|
||||
### Option 3: Docker Source Build (CURRENT)
|
||||
|
||||
**Best for:** Development, customization
|
||||
**Complexity:** High
|
||||
**Maintenance:** High
|
||||
|
||||
**Setup:**
|
||||
1. Extract Zscaler cert
|
||||
2. Generate TLS certs
|
||||
3. Build Docker images (15+ min)
|
||||
4. Configure .env with 15+ variables
|
||||
5. Start docker-compose
|
||||
6. Run migrations
|
||||
7. Install plugins
|
||||
8. Reset admin password
|
||||
9. Configure backup system
|
||||
10. Test everything
|
||||
|
||||
**Pros:**
|
||||
- Full source control
|
||||
- Customizable
|
||||
- No external dependencies
|
||||
|
||||
**Cons:**
|
||||
- Extremely complex
|
||||
- Long build times
|
||||
- Many failure points
|
||||
- Requires Docker expertise
|
||||
|
||||
**Production Ready:** [CRITICAL] **NO** (too complex for operations)
|
||||
|
||||
### Option 4: Kubernetes (FUTURE)
|
||||
|
||||
**Best for:** Multi-site deployment, high availability
|
||||
**Complexity:** Very High
|
||||
**Maintenance:** Medium (with proper GitOps)
|
||||
|
||||
**Status:** Not implemented
|
||||
**Effort:** 2-3 weeks of work
|
||||
|
||||
## What Would Make This Production Ready?
|
||||
|
||||
### Critical Path (Must Have)
|
||||
|
||||
1. **Pre-Built Images in Registry**
|
||||
- Build images in CI/CD
|
||||
- Push to container registry
|
||||
- Sites just `docker pull`
|
||||
- **Effort:** 1 week
|
||||
|
||||
2. **One-Command Setup**
|
||||
- `./deploy.sh` does everything
|
||||
- No manual cert extraction
|
||||
- No manual plugin installation
|
||||
- **Effort:** 3 days
|
||||
|
||||
3. **Automated Plugin Detection**
|
||||
- Scan database schema on boot
|
||||
- Auto-register plugins in JSON
|
||||
- No manual `apply-profile`
|
||||
- **Effort:** 2 days
|
||||
|
||||
4. **Simplified Backup (Option)**
|
||||
- Simple mysqldump + tar mode
|
||||
- Single container, no restic
|
||||
- Good enough for 80% of sites
|
||||
- **Effort:** 1 week
|
||||
|
||||
5. **Windows Installer Improvements**
|
||||
- One-click install
|
||||
- Includes Python runtime
|
||||
- Creates service automatically
|
||||
- **Effort:** 1 week
|
||||
|
||||
### Nice to Have (Future)
|
||||
|
||||
- Helm chart for Kubernetes
|
||||
- Monitoring/metrics built-in
|
||||
- Health check endpoints
|
||||
- Automated testing in CI
|
||||
- Rolling update strategy
|
||||
|
||||
## Honest Recommendations
|
||||
|
||||
### For West Jefferson (Your Site)
|
||||
|
||||
**Recommended:** Option 1 (IIS + Windows Services)
|
||||
|
||||
**Why:**
|
||||
- You already have Windows infrastructure
|
||||
- Simpler to operate
|
||||
- Faster to deploy
|
||||
- Your team knows IIS
|
||||
|
||||
**Steps:**
|
||||
1. Build Windows installer
|
||||
2. Deploy to Windows Server
|
||||
3. Configure IIS reverse proxy
|
||||
4. Import prod database
|
||||
5. Done
|
||||
|
||||
**Timeline:** 2-3 days including testing
|
||||
|
||||
### For New Sites
|
||||
|
||||
**Recommended:** Option 2 (Docker Pre-Built Images)
|
||||
|
||||
**Prerequisites:**
|
||||
- Set up CI/CD to build images
|
||||
- Set up container registry (Docker Hub, Harbor, or AWS ECR)
|
||||
- Create `deploy.sh` script
|
||||
|
||||
**Then sites can:**
|
||||
```bash
|
||||
# 1. Copy .env.example to .env and edit
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Run deployment
|
||||
./deploy.sh
|
||||
|
||||
# 3. Access UI
|
||||
https://your-server:5001
|
||||
```
|
||||
|
||||
**Timeline for CI/CD setup:** 1-2 weeks
|
||||
|
||||
### For Current Docker Source Build
|
||||
|
||||
**Status:** Development/customization only, not for production
|
||||
|
||||
**Use when:**
|
||||
- Testing new features
|
||||
- Developing plugins
|
||||
- Debugging issues
|
||||
- Learning the system
|
||||
|
||||
**Don't use when:**
|
||||
- Deploying to operations
|
||||
- Training new ops staff
|
||||
- Time-sensitive deployments
|
||||
|
||||
## Current Documentation Issues
|
||||
|
||||
### Too Many Guides
|
||||
|
||||
- `LOCAL-DEV-SETUP.md` - Windows development (port 3307, run_dev.py)
|
||||
- `DOCKER-SETUP.md` - Docker source build (complex, 15+ steps)
|
||||
- `PRODUCTION-READINESS.md` - This file (meta/assessment)
|
||||
- `scripts/backup/README.md` - Backup system (3 containers, restic)
|
||||
|
||||
**Problem:** Which one do I follow?
|
||||
|
||||
**Solution:** Create decision tree:
|
||||
- Developer on Windows -> LOCAL-DEV-SETUP.md
|
||||
- Ops deploying to prod -> (DOESN'T EXIST YET - need simplified guide)
|
||||
- Custom development -> DOCKER-SETUP.md
|
||||
- Backup questions -> scripts/backup/README.md
|
||||
|
||||
### Missing Guides
|
||||
|
||||
- **PRODUCTION-DEPLOY.md** - Step-by-step for operations (IIS or Docker pull)
|
||||
- **QUICK-START.md** - 5-minute version for evaluation
|
||||
- **TROUBLESHOOTING.md** - Common issues, not scattered across files
|
||||
- **UPGRADE-GUIDE.md** - How to update between versions
|
||||
|
||||
## What We Did Today (Honest Assessment)
|
||||
|
||||
[OK] **Merged wilm->wj** - 98 commits, wilm features now in wj
|
||||
[OK] **Fixed local dev** - `run_dev.py` works for Windows development
|
||||
[OK] **Documented Docker** - Comprehensive but too complex
|
||||
[OK] **Added backup docs** - System works but needs expertise
|
||||
[WARN] **Docker "production ready"** - Technically works, operationally complex
|
||||
[FAIL] **Simple deployment** - Still requires too many manual steps
|
||||
|
||||
## Next Steps (Priority Order)
|
||||
|
||||
### Immediate (This Week)
|
||||
|
||||
1. **Create QUICK-START.md**
|
||||
- 5-minute eval version
|
||||
- Uses pre-built image (if available) OR local dev
|
||||
- Gets people to working system fast
|
||||
|
||||
2. **Test Windows Installer**
|
||||
- Build installer for wj branch
|
||||
- Deploy to test Windows Server
|
||||
- Document any issues
|
||||
|
||||
3. **Create deployment decision tree**
|
||||
- Flowchart: what's my situation -> which method to use
|
||||
- Don't make users read 4 docs to figure out where to start
|
||||
|
||||
### Short Term (Next Sprint)
|
||||
|
||||
4. **Set up CI/CD for image builds**
|
||||
- GitHub Actions or Jenkins
|
||||
- Build on every push to wj
|
||||
- Push to internal registry
|
||||
|
||||
5. **Create `deploy.sh` script**
|
||||
- One command: `./deploy.sh --env prod`
|
||||
- Handles certs, plugins, migrations automatically
|
||||
|
||||
6. **Simplify backup system**
|
||||
- Add "simple mode" - just mysqldump + tar
|
||||
- Keep restic for advanced sites
|
||||
- Document tradeoffs
|
||||
|
||||
### Long Term (Next Month)
|
||||
|
||||
7. **Auto-plugin detection**
|
||||
- Boot-time schema scan
|
||||
- No manual `apply-profile`
|
||||
|
||||
8. **Consolidate documentation**
|
||||
- One entry point
|
||||
- Clear paths for each audience
|
||||
- Tested by someone who hasn't seen the system
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Is ShopDB production-ready?**
|
||||
|
||||
- **The code:** Yes, it's solid
|
||||
- **Docker source build deployment:** No, too complex
|
||||
- **Windows installer deployment:** Yes, with improvements
|
||||
- **Docker pre-built images deployment:** Yes, but needs CI/CD setup
|
||||
|
||||
**Bottom line:** The application is mature. The deployment story needs work. Focus should shift from "making Docker work" to "making deployment simple."
|
||||
|
||||
**Recommendation:** Invest 2-3 weeks in deployment automation (CI/CD, pre-built images, deploy.sh) rather than continuing to document complex manual processes.
|
||||
199
agent/INSTALL-ON-VM.md
Normal file
199
agent/INSTALL-ON-VM.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Install ShopDB Agent on Hyper-V VM
|
||||
|
||||
This guide shows how to install the fleet agent on your Hyper-V VM to report back to ShopDB running on the host PC.
|
||||
|
||||
## Configuration for Your Setup
|
||||
|
||||
**ShopDB Server (Host PC):**
|
||||
- URL: `https://172.31.96.1:5001`
|
||||
- Collector API Key: `shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43`
|
||||
|
||||
**VM Network:**
|
||||
- VM can reach host at: `172.31.96.1`
|
||||
- Host's Hyper-V virtual adapter IP
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Build the Agent (On Host PC)
|
||||
|
||||
**Install .NET SDK first:**
|
||||
```powershell
|
||||
# On Host PC (this machine):
|
||||
winget install Microsoft.DotNet.SDK.8
|
||||
```
|
||||
|
||||
**Build agent executables:**
|
||||
```powershell
|
||||
cd C:\Users\270015376\Documents\github\flask-shopdb\agent
|
||||
|
||||
# Build service
|
||||
dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\temp\shopdb-agent
|
||||
|
||||
# Build tray app (optional)
|
||||
dotnet publish ShopDbEndpointAgent.Tray -c Release -r win-x64 --self-contained -o C:\temp\shopdb-agent-tray
|
||||
|
||||
# Package for VM
|
||||
Compress-Archive -Path C:\temp\shopdb-agent -DestinationPath C:\temp\shopdb-agent.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Copy to VM
|
||||
|
||||
**Copy the ZIP to your VM:**
|
||||
- Via RDP shared folder
|
||||
- Via network share
|
||||
- Via USB
|
||||
|
||||
**Extract on VM:**
|
||||
```powershell
|
||||
# On VM:
|
||||
Expand-Archive C:\path\to\shopdb-agent.zip -DestinationPath C:\ProgramData\ShopDB\EndpointAgent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Configure Agent (On VM)
|
||||
|
||||
**Set registry configuration:**
|
||||
```powershell
|
||||
# Run as Administrator on VM:
|
||||
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
|
||||
|
||||
# Point to HOST PC's IP (not localhost!)
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://172.31.96.1:5001'
|
||||
|
||||
# Use the collector key
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value 'shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Test Agent (On VM)
|
||||
|
||||
**Test one report:**
|
||||
```powershell
|
||||
cd C:\ProgramData\ShopDB\EndpointAgent
|
||||
.\shopdb-endpoint-agent.exe --once
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
[INFO] Agent starting...
|
||||
[INFO] Collecting endpoint state...
|
||||
[INFO] Reporting to https://172.31.96.1:5001/api/collector/endpoints
|
||||
[INFO] Report successful
|
||||
```
|
||||
|
||||
**If you see SSL certificate errors:**
|
||||
The self-signed cert will cause warnings. You can either:
|
||||
- Ignore them (agent still works)
|
||||
- Or use HTTP (not recommended): `http://172.31.96.1:5001`
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Install as Service (On VM)
|
||||
|
||||
**Create Windows service:**
|
||||
```powershell
|
||||
# Run as Administrator on VM:
|
||||
sc.exe create "ShopDBEndpointAgent" `
|
||||
binPath= "C:\ProgramData\ShopDB\EndpointAgent\shopdb-endpoint-agent.exe" `
|
||||
start= auto `
|
||||
DisplayName= "ShopDB Endpoint Agent"
|
||||
|
||||
sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB server."
|
||||
|
||||
# Start the service
|
||||
sc.exe start "ShopDBEndpointAgent"
|
||||
```
|
||||
|
||||
**Check service status:**
|
||||
```powershell
|
||||
Get-Service ShopDBEndpointAgent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify on Host PC
|
||||
|
||||
**Check ShopDB received the report:**
|
||||
|
||||
1. Open: `https://localhost:5001/computers`
|
||||
2. Look for your VM's hostname
|
||||
3. Click on it to see endpoint data
|
||||
|
||||
**Or check logs:**
|
||||
```bash
|
||||
# On host PC:
|
||||
docker logs flask-shopdb-api-1 | grep -i "collector/endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent can't reach server
|
||||
|
||||
**Test connectivity from VM:**
|
||||
```powershell
|
||||
# On VM:
|
||||
Test-NetConnection -ComputerName 172.31.96.1 -Port 5001
|
||||
```
|
||||
|
||||
**If blocked by firewall:**
|
||||
```powershell
|
||||
# On Host PC, allow inbound:
|
||||
New-NetFirewallRule -DisplayName "ShopDB API" -Direction Inbound -LocalPort 5001 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
### SSL certificate errors
|
||||
|
||||
The agent works even with self-signed cert warnings. To eliminate them:
|
||||
|
||||
**Option 1: Use HTTP (dev only)**
|
||||
```powershell
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'http://172.31.96.1:5001'
|
||||
```
|
||||
|
||||
**Option 2: Trust the cert**
|
||||
- Export cert from host
|
||||
- Import to VM's Trusted Root
|
||||
|
||||
### Agent reports but PC doesn't appear
|
||||
|
||||
**Check collector key matches:**
|
||||
```powershell
|
||||
# On VM:
|
||||
Get-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
|
||||
|
||||
# Should match what's in ShopDB .env:
|
||||
# shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
|
||||
```powershell
|
||||
# Stop and remove service
|
||||
sc.exe stop "ShopDBEndpointAgent"
|
||||
sc.exe delete "ShopDBEndpointAgent"
|
||||
|
||||
# Remove files
|
||||
Remove-Item -Recurse C:\ProgramData\ShopDB\EndpointAgent
|
||||
|
||||
# Remove registry
|
||||
Remove-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Recurse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Host IP:** `172.31.96.1`
|
||||
**ShopDB URL:** `https://172.31.96.1:5001`
|
||||
**Collector Key:** `shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43`
|
||||
**Agent Location:** `C:\ProgramData\ShopDB\EndpointAgent\`
|
||||
**Service Name:** `ShopDBEndpointAgent`
|
||||
**Report Interval:** 15 minutes (configurable in appsettings.json)
|
||||
51
agent/QUICK-FIX-VM.md
Normal file
51
agent/QUICK-FIX-VM.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Quick Fix: Agent Can't Connect from VM
|
||||
|
||||
## Problem
|
||||
SSL certificate errors when connecting from VM to host.
|
||||
|
||||
## Solution: Use HTTP on Local Network
|
||||
|
||||
Since the VM and host are on a trusted local network (Hyper-V), use HTTP instead of HTTPS.
|
||||
|
||||
### On VM - Set HTTP in Registry:
|
||||
|
||||
```powershell
|
||||
# On VM (as Administrator):
|
||||
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'http://172.31.96.1:5001'
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value 'shopdb_pat_b4a3fe2a01abc6bc1cb685280e65a8c35d52ec43'
|
||||
```
|
||||
|
||||
### Test it works:
|
||||
|
||||
```powershell
|
||||
# On VM:
|
||||
curl.exe http://172.31.96.1:5001
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
### Then run the agent:
|
||||
|
||||
```powershell
|
||||
cd C:\ProgramData\ShopDB\EndpointAgent
|
||||
.\shopdb-endpoint-agent.exe --once
|
||||
```
|
||||
|
||||
## Why HTTP is OK Here
|
||||
|
||||
- [X] VM and host on same physical machine (Hyper-V)
|
||||
- [X] Traffic never leaves the host
|
||||
- [X] Hyper-V internal network is isolated
|
||||
- [X] No external network exposure
|
||||
|
||||
For production multi-machine deployment, use HTTPS with proper certificates.
|
||||
|
||||
## Alternative: Fix HTTPS (More Complex)
|
||||
|
||||
If you need HTTPS:
|
||||
|
||||
1. Export the self-signed cert from host
|
||||
2. Import to VM's Trusted Root Certification Authorities
|
||||
3. Use HTTPS URL
|
||||
|
||||
See main INSTALL-ON-VM.md for details.
|
||||
136
deploy-to-vm.ps1
Normal file
136
deploy-to-vm.ps1
Normal file
@@ -0,0 +1,136 @@
|
||||
# ShopDB Docker Deployment Script for Hyper-V VM
|
||||
# Run this ON THE VM after copying the repo
|
||||
|
||||
param(
|
||||
[string]$SiteName = "shopdb-vm",
|
||||
[string]$AdminPassword = "ChangeMe123!",
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
if ($Help) {
|
||||
Write-Host @"
|
||||
ShopDB Docker Deployment Script
|
||||
|
||||
Usage:
|
||||
.\deploy-to-vm.ps1 [-SiteName <name>] [-AdminPassword <password>]
|
||||
|
||||
Parameters:
|
||||
-SiteName Site identifier (default: shopdb-vm)
|
||||
-AdminPassword Initial admin password (default: ChangeMe123!)
|
||||
-Help Show this help
|
||||
|
||||
Example:
|
||||
.\deploy-to-vm.ps1 -SiteName "west-jefferson" -AdminPassword "SecurePass123!"
|
||||
|
||||
Prerequisites:
|
||||
- Docker Desktop installed and running
|
||||
- Git Bash or WSL available
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "ShopDB Docker Deployment" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Check Docker is running
|
||||
Write-Host "[1/8] Checking Docker Desktop..." -ForegroundColor Yellow
|
||||
try {
|
||||
docker ps | Out-Null
|
||||
Write-Host " OK: Docker is running" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " ERROR: Docker Desktop is not running" -ForegroundColor Red
|
||||
Write-Host " Please start Docker Desktop and try again" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if we're in the repo
|
||||
Write-Host "[2/8] Checking repository..." -ForegroundColor Yellow
|
||||
if (-not (Test-Path "docker-compose.prebuilt.yml")) {
|
||||
Write-Host " ERROR: Not in shopdb-flask directory" -ForegroundColor Red
|
||||
Write-Host " Please cd to the repository root" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " OK: In repository root" -ForegroundColor Green
|
||||
|
||||
# Create .env if it doesn't exist
|
||||
Write-Host "[3/8] Configuring environment..." -ForegroundColor Yellow
|
||||
if (-not (Test-Path ".env")) {
|
||||
Copy-Item ".env.docker-dev" ".env"
|
||||
Write-Host " Created .env from template" -ForegroundColor Green
|
||||
|
||||
# Update site name
|
||||
(Get-Content ".env") -replace 'SHOPDB_SITE=shopdb-dev', "SHOPDB_SITE=$SiteName" | Set-Content ".env"
|
||||
(Get-Content ".env") -replace 'SHOPDB_ENV_LABEL=DOCKER-DEV', "SHOPDB_ENV_LABEL=$SiteName" | Set-Content ".env"
|
||||
Write-Host " Configured for site: $SiteName" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " OK: .env already exists" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Build frontend if needed
|
||||
Write-Host "[4/8] Checking frontend build..." -ForegroundColor Yellow
|
||||
if (-not (Test-Path "frontend/dist/index.html")) {
|
||||
Write-Host " Building frontend (this may take 2-3 minutes)..." -ForegroundColor Yellow
|
||||
Set-Location frontend
|
||||
npm ci
|
||||
npm run build
|
||||
Set-Location ..
|
||||
Write-Host " OK: Frontend built" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " OK: Frontend already built" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Build Docker images
|
||||
Write-Host "[5/8] Building Docker images..." -ForegroundColor Yellow
|
||||
Write-Host " This will take 2-3 minutes (using offline packages)..." -ForegroundColor Yellow
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml build
|
||||
Write-Host " OK: Images built" -ForegroundColor Green
|
||||
|
||||
# Start services
|
||||
Write-Host "[6/8] Starting services..." -ForegroundColor Yellow
|
||||
docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml --profile backup up -d
|
||||
Start-Sleep -Seconds 10
|
||||
Write-Host " OK: Services started" -ForegroundColor Green
|
||||
|
||||
# Run migrations
|
||||
Write-Host "[7/8] Initializing database..." -ForegroundColor Yellow
|
||||
docker exec flask-shopdb-api-1 flask db upgrade
|
||||
Write-Host " OK: Database initialized" -ForegroundColor Green
|
||||
|
||||
# Set admin password
|
||||
Write-Host "[8/8] Setting admin password..." -ForegroundColor Yellow
|
||||
$passwordHash = docker exec flask-shopdb-api-1 python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('$AdminPassword'))"
|
||||
$dbPassword = (Get-Content ".env" | Select-String "MYSQL_ROOT_PASSWORD=(.+)" | ForEach-Object { $_.Matches.Groups[1].Value })
|
||||
|
||||
docker exec flask-shopdb-db-1 mysql -uroot -p$dbPassword shopdb_flask -e @"
|
||||
UPDATE users SET
|
||||
passwordhash = '$passwordHash',
|
||||
failedlogins = 0,
|
||||
lockeduntil = NULL
|
||||
WHERE username = 'admin';
|
||||
"@
|
||||
Write-Host " OK: Admin password set" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "Deployment Complete!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Access ShopDB at: https://localhost:5001" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Login credentials:" -ForegroundColor Yellow
|
||||
Write-Host " Username: admin" -ForegroundColor White
|
||||
Write-Host " Password: $AdminPassword" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Services running:" -ForegroundColor Yellow
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
Write-Host ""
|
||||
Write-Host "To view logs:" -ForegroundColor Yellow
|
||||
Write-Host " docker logs -f flask-shopdb-api-1" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "To stop:" -ForegroundColor Yellow
|
||||
Write-Host " docker compose -f docker-compose.prebuilt.yml -f docker-compose.backup.yml down" -ForegroundColor White
|
||||
Write-Host ""
|
||||
@@ -101,6 +101,17 @@
|
||||
</rewrite>
|
||||
SHOPDB-CLIENTIP-END -->
|
||||
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<!-- IIS request filtering caps a request body at 30,000,000 bytes by
|
||||
default and answers 413 before waitress sees it. The agent package
|
||||
upload accepts 128 MB (MAX_CONTENT_LENGTH is 256 MB), and the tray
|
||||
binary alone is ~69 MB, so the IIS cap has to be raised to match or
|
||||
every agent upload fails on Windows while working under Docker. -->
|
||||
<requestLimits maxAllowedContentLength="268435456" />
|
||||
</requestFiltering>
|
||||
</security>
|
||||
|
||||
</system.webServer>
|
||||
|
||||
<!--
|
||||
|
||||
76
docker-compose.prebuilt.yml
Normal file
76
docker-compose.prebuilt.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
# Docker Compose using pre-built frontend
|
||||
# Build frontend on host first: cd frontend && npm run build
|
||||
# Then: docker compose -f docker-compose.prebuilt.yml build
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.4
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: shopdb_flask
|
||||
MYSQL_USER: shopdb
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prebuilt
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- FLASK_ENV=${FLASK_ENV:-production}
|
||||
- FLASK_DEBUG=${FLASK_DEBUG:-0}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||
- SHOPDB_ENV_LABEL=${SHOPDB_ENV_LABEL}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- ZABBIX_URL=${ZABBIX_URL:-}
|
||||
- ZABBIX_TOKEN=${ZABBIX_TOKEN:-}
|
||||
- COLLECTOR_API_KEY=${COLLECTOR_API_KEY:-}
|
||||
- COLLECTOR_API_KEY_ENDPOINTS=${COLLECTOR_API_KEY_ENDPOINTS:-}
|
||||
volumes:
|
||||
- instance_data:/app/instance
|
||||
- ./plugins:/app/plugins:ro
|
||||
- ./scripts:/app/scripts:ro
|
||||
- ./certs:/app/certs:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- gunicorn
|
||||
- --bind
|
||||
- 0.0.0.0:5001
|
||||
- --worker-class
|
||||
- gevent
|
||||
- --workers
|
||||
- "4"
|
||||
- --worker-connections
|
||||
- "2000"
|
||||
- --timeout
|
||||
- "60"
|
||||
- --access-logfile
|
||||
- "-"
|
||||
- --access-logformat
|
||||
- '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"'
|
||||
- --error-logfile
|
||||
- "-"
|
||||
- --certfile
|
||||
- /app/certs/server.crt
|
||||
- --keyfile
|
||||
- /app/certs/server.key
|
||||
- wsgi:app
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
instance_data:
|
||||
@@ -194,6 +194,9 @@
|
||||
},
|
||||
{
|
||||
"name": "endpoints-siteagent"
|
||||
},
|
||||
{
|
||||
"name": "core-shopdbbackups"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
@@ -18765,9 +18768,8 @@
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).",
|
||||
"description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** jwt-optional\n\n**Params:** ip or fqdn (one required; value matched against communication ipaddress)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```",
|
||||
"description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** printers.view\n\n**Params:** ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```",
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
@@ -18788,6 +18790,12 @@
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23332,6 +23340,414 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/hardware": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the...",
|
||||
"description": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the columns null and the panel hides itself rather than rendering blanks\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/hardware\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/osinfo": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box...",
|
||||
"description": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box running\")\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/osinfo\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/storage": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical...",
|
||||
"description": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical devices) and are never reconciled across sections\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/storage\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/diskhealth": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage...",
|
||||
"description": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage renders it as a section) but kept as a stable read for callers outside the panel renderer\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/diskhealth\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/listeners": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an...",
|
||||
"description": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an on-demand command, not standard telemetry\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/listeners\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/mappeddrives": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is...",
|
||||
"description": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is signed in is a valid state, not a failure\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/mappeddrives\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/usercontext": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can...",
|
||||
"description": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can disagree and the worse status wins, so a half-broken share is never shown healthy\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/usercontext\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/asset/{asset_id}/iphistory": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row...",
|
||||
"description": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row moved, NOT when the address was last seen\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/iphistory\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scripts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -24394,6 +24810,418 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build",
|
||||
"description": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing...",
|
||||
"description": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing behind it\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/{snapshotid}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Return one set's full manifest plus a resolved compatibility verdict",
|
||||
"description": "Return one set's full manifest plus a resolved compatibility verdict\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "snapshotid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/{snapshotid}/restore": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table",
|
||||
"description": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z/restore\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "snapshotid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "path: snapshotid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/finalize-restore": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does...",
|
||||
"description": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does NOT run the upgrade in the request: that watcher runs it out of band, so a restore completes whether or not the browser that started it is still open, and Alembic cannot be killed by the worker timeout mid-chain\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/finalize-restore\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Live status of the most recent backup and restore runs, or idle when neither is moving",
|
||||
"description": "Live status of the most recent backup and restore runs, or idle when neither is moving\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/status\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/history": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Recorded backup and restore run history, newest first",
|
||||
"description": "Recorded backup and restore run history, newest first\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/history\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/shopdbbackups/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Read the backup settings, with secret values masked",
|
||||
"description": "Read the backup settings, with secret values masked\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/settings\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"core-shopdbbackups"
|
||||
],
|
||||
"summary": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone",
|
||||
"description": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone\n\n**Auth:** admin\n\n**Params:** body: backup settings keys\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"backup_schedule\":\"0 2 * * *\"}' http://localhost:5001/api/shopdbbackups/settings\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "body: backup settings keys"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,9 @@
|
||||
<div v-if="showDisplays" class="nav-section">Displays</div>
|
||||
<a v-if="showShopfloor" :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
||||
<a v-if="showPartsKiosk" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
||||
<a v-if="showUsbKiosk" :href="withBase('/usb-kiosk')"
|
||||
target="_blank" class="external-link">USB Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
@@ -127,6 +127,7 @@ import { dashboardApi, notificationsApi } from '../api'
|
||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '../composables/enabledPlugins'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -138,15 +139,36 @@ const authStore = useAuthStore()
|
||||
// dead-ends on a blank page. Route existence is the true "is it in this build"
|
||||
// test - more reliable than plugin-enabled state, which a copied registry can
|
||||
// report for a plugin that was never staged.
|
||||
//
|
||||
// The kiosks need BOTH tests. Staged-but-disabled is a real state: a full build
|
||||
// stages every route, so a site that turns the plugin OFF still had the link,
|
||||
// pointing at a kiosk its backend refuses. Enabled-but-unstaged is the state the
|
||||
// paragraph above is about, and isPluginEnabled fails OPEN when it cannot reach
|
||||
// the plugin list, so it cannot carry the test on its own.
|
||||
const stagedRoutePaths = new Set(router.getRoutes().map(r => r.path))
|
||||
const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||
|
||||
// Make kiosk visibility reactive - updates when plugin list loads
|
||||
const pluginsLoaded = ref(false)
|
||||
|
||||
// Shopfloor Dashboard is a core view but runs entirely on the notifications
|
||||
// plugin (notificationsApi.getShopfloor + the calendar); without it the display
|
||||
// is empty, so gate the link on the notifications plugin being in this build.
|
||||
const showShopfloor = hasRoute('/shopfloor')
|
||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
||||
|| hasRoute('/usb-kiosk')
|
||||
const showShopfloor = computed(() =>
|
||||
hasRoute('/shopfloor') && (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
)
|
||||
|
||||
const showPartsKiosk = computed(() =>
|
||||
pluginsLoaded.value && hasRoute('/parts-kiosk') && isPluginEnabled('printedparts')
|
||||
)
|
||||
|
||||
const showUsbKiosk = computed(() =>
|
||||
pluginsLoaded.value && hasRoute('/usb-kiosk') && isPluginEnabled('usb')
|
||||
)
|
||||
|
||||
const showDisplays = computed(() =>
|
||||
showShopfloor.value || hasRoute('/tv') || showPartsKiosk.value || showUsbKiosk.value
|
||||
)
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
@@ -285,6 +307,10 @@ onMounted(async () => {
|
||||
// granted mid-session stays invisible until the person logs out and back in.
|
||||
if (authStore.isAuthenticated) authStore.fetchUser()
|
||||
|
||||
// Load enabled plugins list early so kiosk links render correctly
|
||||
await loadEnabledPlugins()
|
||||
pluginsLoaded.value = true
|
||||
|
||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||
// reload on a screen nobody touches.
|
||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
<div class="form-group">
|
||||
<label>Site logo <span class="hint">(optional)</span></label>
|
||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
||||
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
||||
<input v-model="logoFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="siteLogo" :src="withBase(siteLogo)" class="wizard-map-thumb" alt="site logo" />
|
||||
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,15 +93,17 @@
|
||||
<!-- Floor map -->
|
||||
<div v-else-if="current.key === 'map'">
|
||||
<h2>Floor map</h2>
|
||||
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
|
||||
<p class="hint">Upload your facility blueprint (light and dark) for the default level, and set its native pixel size. Additional levels can be added later in Settings.</p>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (light theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintLightFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (dark theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintDarkFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -203,14 +206,24 @@ const blueprintLight = ref('')
|
||||
const blueprintDark = ref('')
|
||||
const logoUploading = ref(false)
|
||||
const siteLogo = ref('')
|
||||
// A file input shows the chosen name in the browser chrome, which the kiosk-
|
||||
// style full-screen wizard hides, so the operator had no confirmation that the
|
||||
// file they picked was the one being uploaded.
|
||||
const logoFilename = ref('')
|
||||
const blueprintLightFilename = ref('')
|
||||
const blueprintDarkFilename = ref('')
|
||||
|
||||
async function uploadSiteLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
logoFilename.value = file.name
|
||||
logoUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
||||
siteLogo.value = data?.data?.value || ''
|
||||
// Same path every time, so without a cache-buster the preview shows the
|
||||
// PREVIOUS logo and the upload reads as having silently failed. Display
|
||||
// only - nothing persists this value.
|
||||
siteLogo.value = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
toast.success('Logo uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
@@ -223,10 +236,12 @@ async function uploadSiteLogo(event) {
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
if (theme === 'light') blueprintLightFilename.value = file.name
|
||||
else blueprintDarkFilename.value = file.name
|
||||
mapUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
||||
const url = data?.data?.value
|
||||
const url = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
if (theme === 'light') blueprintLight.value = url
|
||||
else blueprintDark.value = url
|
||||
toast.success('Blueprint uploaded')
|
||||
@@ -307,6 +322,17 @@ onMounted(async () => {
|
||||
} catch (err) { /* not set yet */ }
|
||||
}
|
||||
}
|
||||
// Show whatever logo the site is already on, so re-running the wizard does
|
||||
// not present an empty slot as if branding had never been set. Stored as a
|
||||
// root-absolute path; the template applies withBase, so do NOT prefix here or
|
||||
// a subpath mount gets it twice (/shopdb/shopdb/...).
|
||||
const SHIPPED_LOGO = '/ge-aerospace-logo.svg'
|
||||
try {
|
||||
const response = await settingsApi.get('site_logo')
|
||||
siteLogo.value = response.data?.data?.value || SHIPPED_LOGO
|
||||
} catch (err) {
|
||||
siteLogo.value = SHIPPED_LOGO
|
||||
}
|
||||
})
|
||||
|
||||
async function saveStep() {
|
||||
|
||||
31
open-firewall-for-vm.ps1
Normal file
31
open-firewall-for-vm.ps1
Normal file
@@ -0,0 +1,31 @@
|
||||
# Open Windows Firewall for ShopDB VM Access
|
||||
# Run this as Administrator on the Host PC
|
||||
|
||||
Write-Host "Adding Windows Firewall rule for ShopDB API..." -ForegroundColor Cyan
|
||||
|
||||
try {
|
||||
New-NetFirewallRule `
|
||||
-DisplayName "ShopDB API - Port 5001" `
|
||||
-Direction Inbound `
|
||||
-LocalPort 5001 `
|
||||
-Protocol TCP `
|
||||
-Action Allow `
|
||||
-Profile Any `
|
||||
-ErrorAction Stop
|
||||
|
||||
Write-Host "SUCCESS: Firewall rule added!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "ShopDB is now accessible from VMs at:" -ForegroundColor Yellow
|
||||
Write-Host " https://172.31.96.1:5001" -ForegroundColor White
|
||||
Write-Host " https://172.30.0.1:5001" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Test from VM with:" -ForegroundColor Yellow
|
||||
Write-Host " Test-NetConnection -ComputerName 172.31.96.1 -Port 5001" -ForegroundColor White
|
||||
Write-Host ""
|
||||
} catch {
|
||||
Write-Host "ERROR: Failed to add firewall rule" -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Make sure you're running PowerShell as Administrator!" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
264
plugins/computers/client/Set-ShopDBDeviceId.ps1
Normal file
264
plugins/computers/client/Set-ShopDBDeviceId.ps1
Normal file
@@ -0,0 +1,264 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Seed HKLM:\SOFTWARE\GE\ShopDB\DeviceId from whatever identity a bay already
|
||||
carries, so device identity can move off C:\Enrollment and into the
|
||||
registry.
|
||||
|
||||
.DESCRIPTION
|
||||
Phase 3 of docs/proposals/device-identity.md. The registry is the
|
||||
destination because it survives the profile cleanup and disk hygiene that
|
||||
eats C:\Enrollment, is readable as SYSTEM with no file ACL to get wrong, and
|
||||
is already where the collector looks for BaseUrl and ApiToken.
|
||||
|
||||
This script does the one-time backfill: it reads the identity a bay holds
|
||||
today and writes it to DeviceId. It is idempotent and safe to run every
|
||||
GE-Enforce cycle.
|
||||
|
||||
THE SOURCE DEPENDS ON THE PC TYPE, AND THAT IS THE WHOLE POINT.
|
||||
|
||||
DeviceId names the DEVICE hanging off this PC. A machine number is a
|
||||
different fact - "which bay is this" rather than "which device is this" -
|
||||
and the two coincide for exactly one family:
|
||||
|
||||
CMM bays the machine number IS the instrument id (CMM4), which is
|
||||
why cmmid.txt has always doubled as identity. Copying it
|
||||
is correct.
|
||||
|
||||
part markers the machine number is the OPERATION (0613), and several
|
||||
markers serve one operation. Copying it would name the
|
||||
operation as the device. The collector refuses that
|
||||
("asset X is not a Part Marker; not linked") because a
|
||||
machine filed under a device label reads as a marker
|
||||
everywhere downstream. So machine number is NEVER a
|
||||
source here.
|
||||
|
||||
Keyence, no per-bay id exists at all. asset-id.txt is the only
|
||||
Genspect, identity these have ever had.
|
||||
wax-trace
|
||||
|
||||
everything not a device bay. DeviceId is not consumed for these
|
||||
else pc-types, so writing one is a landmine for the day that
|
||||
pc-type gains a device spec. Writes nothing.
|
||||
|
||||
9999 is the unassigned placeholder and is skipped everywhere, matching
|
||||
Report-AssetToShopDB.ps1 and the server.
|
||||
|
||||
.PARAMETER Force
|
||||
Overwrite a DeviceId that is already set to a DIFFERENT value.
|
||||
|
||||
Off by default on purpose. asset-id.txt is written ONCE at imaging and
|
||||
nothing updates it when a device is swapped, so a stale file overwriting a
|
||||
corrected registry value would re-introduce the exact defect this move is
|
||||
meant to fix. A disagreement is reported and left alone for a human.
|
||||
|
||||
.PARAMETER PCType
|
||||
Override the pc-type instead of reading C:\Enrollment\pc-type.txt. For
|
||||
testing and for a bay whose file is missing.
|
||||
|
||||
.PARAMETER DeviceId
|
||||
Write this value directly, skipping resolution. Still honours -Force and
|
||||
the 9999 rule.
|
||||
|
||||
.PARAMETER LogPath
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBDeviceId.ps1 -WhatIf
|
||||
Show what would be written on this bay without touching the registry.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBDeviceId.ps1
|
||||
Seed DeviceId. Safe to run repeatedly and from a GE-Enforce PS1 entry.
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 wrote a value, it was already correct, or this is not a device bay
|
||||
1 an error stopped the write
|
||||
2 an existing DeviceId disagrees with the resolved value; needs a human
|
||||
or -Force
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$PCType,
|
||||
[string]$DeviceId,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RegPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
$RegValueName = 'DeviceId'
|
||||
$Placeholder = '9999'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = "{0} [Set-ShopDBDeviceId] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Output $line
|
||||
if ($LogPath) {
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Read-FirstLine {
|
||||
# One line, trimmed, empty when absent or unreadable. A bay missing a file
|
||||
# is the normal case, not an error.
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return '' }
|
||||
try {
|
||||
$v = [string](Get-Content -LiteralPath $Path -First 1 -ErrorAction Stop)
|
||||
return $v.Trim()
|
||||
} catch {
|
||||
Log "WARN could not read ${Path}: $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MachineNoFromRegistry {
|
||||
# eDNC/NTLARS, WOW6432Node first, matching Report-AssetToShopDB.ps1.
|
||||
foreach ($rp in @(
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
|
||||
)) {
|
||||
try {
|
||||
if (Test-Path $rp) {
|
||||
$v = [string](Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
|
||||
if ($v) {
|
||||
$v = $v.Trim()
|
||||
if ($v -and $v -ne $Placeholder) { return $v }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not read MachineNo from ${rp}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
# Which pc-types drive a device, and whether the machine number is a legitimate
|
||||
# identity for that device. Mirrors SUBORDINATE_DEVICE_MAP in
|
||||
# plugins/computers/pctypemap.py. A site that added its own device pc-type
|
||||
# through a subordinatedevice_<pctype> setting adds it here too.
|
||||
$DeviceFamilies = @{
|
||||
'gea-shopfloor-cmm' = @{ Label = 'CMM'; MachineNoIsDevice = $true }
|
||||
'gea-shopfloor-keyence' = @{ Label = 'Vision System'; MachineNoIsDevice = $false }
|
||||
'gea-shopfloor-genspect' = @{ Label = 'Genspect'; MachineNoIsDevice = $false }
|
||||
'gea-shopfloor-waxtrace' = @{ Label = 'Form Tracer'; MachineNoIsDevice = $false }
|
||||
'gea-shopfloor-partmarker'= @{ Label = 'Part Marker'; MachineNoIsDevice = $false }
|
||||
}
|
||||
|
||||
# --- 1. what kind of bay is this -------------------------------------------
|
||||
|
||||
if (-not $PCType) { $PCType = Read-FirstLine 'C:\Enrollment\pc-type.txt' }
|
||||
|
||||
if (-not $PCType) {
|
||||
Log 'no pc-type on this PC (C:\Enrollment\pc-type.txt missing or empty); nothing to do'
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $DeviceFamilies.ContainsKey($PCType)) {
|
||||
Log "pc-type '$PCType' does not drive a device; DeviceId not written"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$family = $DeviceFamilies[$PCType]
|
||||
Log "pc-type '$PCType' drives a $($family.Label)"
|
||||
|
||||
# --- 2. resolve the identity ------------------------------------------------
|
||||
|
||||
$resolved = ''
|
||||
$source = ''
|
||||
|
||||
if ($DeviceId) {
|
||||
$resolved = $DeviceId.Trim()
|
||||
$source = '-DeviceId parameter'
|
||||
} else {
|
||||
# Most stable identity first, and the machine number only where it really
|
||||
# names the device.
|
||||
if ($family.MachineNoIsDevice) {
|
||||
$v = Read-FirstLine 'C:\Enrollment\cmm\cmmid.txt'
|
||||
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\cmm\cmmid.txt' }
|
||||
|
||||
if (-not $resolved) {
|
||||
$v = Get-MachineNoFromRegistry
|
||||
if ($v) { $resolved = $v; $source = 'eDNC MachineNo registry' }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $resolved) {
|
||||
$v = Read-FirstLine 'C:\Enrollment\asset-id.txt'
|
||||
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\asset-id.txt' }
|
||||
}
|
||||
|
||||
if (-not $resolved) {
|
||||
# The 0.12.0 name, before the file was generalised.
|
||||
$v = Read-FirstLine 'C:\Enrollment\measuringtool-id.txt'
|
||||
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\measuringtool-id.txt' }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $resolved) {
|
||||
# Nothing to copy. Silence is the right answer: the collector still adopts
|
||||
# or mints as it does today, and inventing a value here would name a device
|
||||
# that nobody can account for.
|
||||
Log "no identity found for this $($family.Label) bay; DeviceId not written"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($resolved -eq $Placeholder) {
|
||||
Log "resolved value is the $Placeholder placeholder; DeviceId not written"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Log "resolved DeviceId '$resolved' from $source"
|
||||
|
||||
# --- 3. write it ------------------------------------------------------------
|
||||
|
||||
$existing = ''
|
||||
try {
|
||||
if (Test-Path $RegPath) {
|
||||
$prop = Get-ItemProperty -Path $RegPath -Name $RegValueName -ErrorAction SilentlyContinue
|
||||
if ($prop) { $existing = [string]$prop.$RegValueName; $existing = $existing.Trim() }
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not read existing ${RegValueName}: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($existing -eq $resolved) {
|
||||
Log "DeviceId already '$resolved'; nothing to do"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($existing -and -not $Force) {
|
||||
# The registry value wins by default. C:\Enrollment is written once at
|
||||
# imaging and never updated on a device swap, so the file is the more
|
||||
# likely one to be wrong.
|
||||
Log "REFUSING to overwrite: DeviceId is '$existing', $source says '$resolved'"
|
||||
Log 'Re-run with -Force if the file is right, or fix the file if the registry is.'
|
||||
exit 2
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $RegPath)) {
|
||||
if ($PSCmdlet.ShouldProcess($RegPath, 'Create registry key')) {
|
||||
New-Item -Path $RegPath -Force | Out-Null
|
||||
Log "created $RegPath"
|
||||
}
|
||||
}
|
||||
$action = if ($existing) { "Set $RegValueName '$existing' -> '$resolved'" }
|
||||
else { "Set $RegValueName '$resolved'" }
|
||||
if ($PSCmdlet.ShouldProcess($RegPath, $action)) {
|
||||
New-ItemProperty -Path $RegPath -Name $RegValueName -Value $resolved `
|
||||
-PropertyType String -Force | Out-Null
|
||||
Log "wrote $RegPath\$RegValueName = '$resolved'"
|
||||
} else {
|
||||
Log "WHATIF would write $RegPath\$RegValueName = '$resolved'"
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR could not write ${RegValueName}: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -174,7 +174,9 @@ async function loadData() {
|
||||
}
|
||||
|
||||
if (types.status === 'fulfilled') {
|
||||
pctypes.value = types.value.data.data || []
|
||||
// Plain array, same as applications below. Reading .data.data threw
|
||||
// inside this try, so apps, actions and monitors never got assigned.
|
||||
pctypes.value = types.value || []
|
||||
} else {
|
||||
failures.push('PC types')
|
||||
console.error('Error loading PC types:', types.reason)
|
||||
|
||||
@@ -1121,7 +1121,10 @@ class EndpointsPlugin(BasePlugin):
|
||||
if 'osinfo' in payload:
|
||||
state.osinfojson = _as_json(payload.get('osinfo'))
|
||||
if 'domain' in payload:
|
||||
state.domain = payload.get('domain')
|
||||
# Clamped like every other String column here: an over-long value
|
||||
# aborts the whole collector transaction under STRICT_TRANS_TABLES,
|
||||
# so one bad agent report would drop a machine's entire telemetry.
|
||||
state.domain = _clamp(payload.get('domain'), 255)
|
||||
if 'pendingreboot' in payload:
|
||||
state.pendingreboot = payload.get('pendingreboot')
|
||||
if 'diskhealth' in payload:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Knowledge Base API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
intarg,
|
||||
@@ -180,21 +179,29 @@ def get_article(link_id: int):
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
||||
@jwt_required(optional=True)
|
||||
@require_permission('kb.view')
|
||||
def track_click(link_id: int):
|
||||
"""Increment click counter and return the URL to redirect to."""
|
||||
article = db.session.get(KnowledgeBase, link_id)
|
||||
"""Increment the click counter for an article the caller is allowed to read.
|
||||
|
||||
if not article or not article.isactive:
|
||||
Gated on kb.view like every other read of this table: an anonymous caller
|
||||
walking link ids must not be able to probe which articles exist, drive the
|
||||
counters that order the list, or spend a DB transaction per request.
|
||||
|
||||
Restricted to the same visible set as the list/detail reads so an article
|
||||
about a retired application is not reachable here either.
|
||||
|
||||
The linkurl is deliberately not echoed back: whoever is permitted to click
|
||||
already received it in the list or detail response the click came from.
|
||||
"""
|
||||
article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first()
|
||||
|
||||
if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.increment_clicks()
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'linkurl': article.linkurl,
|
||||
'clicks': article.clicks
|
||||
})
|
||||
return success_response({'clicks': article.clicks})
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['POST'])
|
||||
|
||||
22
run_dev.py
Normal file
22
run_dev.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Development server launcher for ShopDB.
|
||||
Loads .env file and starts Flask development server.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Load .env BEFORE importing anything from Flask
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
|
||||
print("Starting ShopDB Flask development server...")
|
||||
print(f"DATABASE_URL: {os.environ.get('DATABASE_URL', 'NOT SET')[:60]}...")
|
||||
print(f"SHOPDB_BACKUPS_ROOT: {os.environ.get('SHOPDB_BACKUPS_ROOT', 'NOT SET')}")
|
||||
|
||||
# Import and run Flask (--no-reload so env vars don't get lost)
|
||||
from flask.cli import main as flask_main
|
||||
sys.argv = ['flask', 'run', '--host=0.0.0.0', '--port=5001', '--no-reload']
|
||||
flask_main()
|
||||
@@ -1,38 +1,25 @@
|
||||
# Backup sidecar image: mysql:8.4 (for a version-matched mysqldump) plus restic
|
||||
# (for deduped, encrypted, destination-agnostic file snapshots) and a small
|
||||
# Python for the manifest writer.
|
||||
# Backup sidecar image: mysql:8.4 base (mysqldump already included) plus
|
||||
# offline-packaged restic for deduped, encrypted backups.
|
||||
#
|
||||
# WHY mysql:8.4 as the base: the dump tool must match the server major/minor so
|
||||
# the SQL it emits restores cleanly onto the same engine. Reusing the exact
|
||||
# image the db service runs removes version drift as a failure mode.
|
||||
# OFFLINE BUILD: No package downloads during build to avoid corporate proxy SSL issues.
|
||||
# - mysqldump: included in mysql:8.4 base image (version-matched)
|
||||
# - restic: pre-downloaded binary baked into repo at scripts/backup/offline-packages/
|
||||
# - NO Python/alembic: simplified backup without manifest generation
|
||||
#
|
||||
# This works behind Zscaler/corporate proxies because nothing downloads during build.
|
||||
FROM mysql:8.4
|
||||
|
||||
# Track 1 local build unblock: trust the corporate proxy (Zscaler/GE) root CA so
|
||||
# the build-time fetches below (Oracle yum, github.com, PyPI) succeed behind TLS
|
||||
# interception. This is a local convenience only; the shipping build vendors
|
||||
# these deps instead of fetching them (pinned restic + SHA256, vendored
|
||||
# bzip2/python3/alembic or a base image that carries them) so the release image
|
||||
# needs no outbound calls and no embedded cert. The cert is gitignored (*.pem)
|
||||
# and must be staged at scripts/backup/zscaler-root-ca.pem before building.
|
||||
COPY scripts/backup/zscaler-root-ca.pem /etc/pki/ca-trust/source/anchors/zscaler-root-ca.pem
|
||||
RUN update-ca-trust
|
||||
# Copy pre-downloaded restic binary (offline package - baked into repo)
|
||||
COPY scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2 /tmp/restic.bz2
|
||||
|
||||
# restic from the distro is often stale; pull the pinned release binary so the
|
||||
# snapshot format and retention flags are predictable across sites.
|
||||
ARG RESTIC_VERSION=0.17.3
|
||||
RUN microdnf install -y bzip2 python3 python3-pip ca-certificates \
|
||||
&& curl -fsSL -o /tmp/restic.bz2 \
|
||||
"https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" \
|
||||
&& bunzip2 /tmp/restic.bz2 \
|
||||
# Install restic from offline package (no downloads, no SSL issues)
|
||||
RUN bunzip2 /tmp/restic.bz2 \
|
||||
&& install -m 0755 /tmp/restic /usr/local/bin/restic \
|
||||
&& rm -f /tmp/restic \
|
||||
&& microdnf clean all
|
||||
&& restic version
|
||||
|
||||
# alembic so the manifest writer can read the current schema head. pip does not
|
||||
# read the system trust store, so point it at the extracted system CA bundle
|
||||
# (now including the proxy root) to get through TLS interception.
|
||||
RUN python3 -m pip install --no-cache-dir \
|
||||
--cert /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
|
||||
alembic
|
||||
# Copy backup scripts into image
|
||||
COPY scripts/backup/*.sh /opt/backup/
|
||||
RUN chmod +x /opt/backup/*.sh
|
||||
|
||||
ENTRYPOINT ["/opt/backup/backup.sh"]
|
||||
|
||||
97
scripts/backup/README.md
Normal file
97
scripts/backup/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# ShopDB Backup & Restore
|
||||
|
||||
The backup system provides automated database and instance backups with restore capability.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Trigger a backup via docker-compose
|
||||
docker compose -f docker-compose.backup.yml up backup
|
||||
|
||||
# Or via API (requires admin JWT token)
|
||||
curl -X POST http://localhost:5001/api/shopdbbackups \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, backups are stored in `/backups` volume (local to Docker host).
|
||||
|
||||
For production, configure storage in Settings > Backups web UI:
|
||||
- **Local**: Default, no configuration needed
|
||||
- **SMB**: Network share (requires hostname, share, credentials)
|
||||
- **AWS S3**: Cloud storage (requires region, bucket, credentials)
|
||||
|
||||
## Retention Policy
|
||||
|
||||
- 5 daily backups
|
||||
- 4 weekly backups
|
||||
- 6 monthly backups
|
||||
|
||||
## Backup Contents
|
||||
|
||||
Each backup set includes:
|
||||
- `db.sql.gz` - Compressed MySQL dump
|
||||
- `instance/` - Uploaded files, branding, floor plans, maps
|
||||
- `manifest.json` - Schema revisions for compatibility checking
|
||||
|
||||
## Restore
|
||||
|
||||
1. Go to **Settings > Backups** in web UI
|
||||
2. Select a backup set
|
||||
3. Click **Restore**
|
||||
4. System validates schema compatibility and restores database + files
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
For local dev on Windows, configure a local backup directory:
|
||||
|
||||
1. Add to `.env`:
|
||||
```
|
||||
SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups
|
||||
```
|
||||
|
||||
2. Create the directory:
|
||||
```bash
|
||||
mkdir backups
|
||||
```
|
||||
|
||||
3. Restart Flask
|
||||
|
||||
Note: Actual backup execution requires Docker (the backup container). The API can queue backups but won't execute them without the sidecar container.
|
||||
|
||||
## First Deployment
|
||||
|
||||
On fresh install, `/api/shopdbbackups` returns empty list - this is normal.
|
||||
No backups exist until first backup runs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No backups found"**
|
||||
- Check `/backups` volume is mounted
|
||||
- Verify backup container ran: `docker logs shopdb-backup-1`
|
||||
|
||||
**"500 error on backup endpoint"**
|
||||
- Must be logged in as admin
|
||||
- Check Flask logs for details
|
||||
|
||||
**"Restore failed: incompatible schema"**
|
||||
- Backup is from newer ShopDB version
|
||||
- Update code first, then restore
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All require `admin` role:
|
||||
|
||||
- `GET /api/shopdbbackups` - List backups
|
||||
- `POST /api/shopdbbackups` - Trigger backup
|
||||
- `POST /api/shopdbbackups/<id>/restore` - Restore backup
|
||||
- `GET /api/shopdbbackups/status` - Current status
|
||||
- `GET /api/shopdbbackups/history` - Backup history
|
||||
|
||||
## Security
|
||||
|
||||
- Backups contain sensitive data (DB dump, uploads)
|
||||
- Secure `/backups` volume with appropriate permissions
|
||||
- Use encrypted storage for offsite backups
|
||||
- Only admin role can access backup endpoints
|
||||
67
scripts/backup/backup.sh
Normal file → Executable file
67
scripts/backup/backup.sh
Normal file → Executable file
@@ -76,26 +76,42 @@ DATAREV="$(mysql \
|
||||
[ -n "${DATAREV}" ] || log "WARN could not read alembic_version - datarev will be empty"
|
||||
|
||||
log "writing manifest"
|
||||
python3 /opt/backup/manifest.py \
|
||||
--out "${SETDIR}/manifest.json" \
|
||||
--takenat "${STAMP}" \
|
||||
--site "${SITE}" \
|
||||
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
|
||||
--datarev "${DATAREV}" \
|
||||
--migrations /app/migrations \
|
||||
--plugins /app/plugins
|
||||
# Manifest generation requires Python. If not available (offline build), create
|
||||
# a minimal manifest with just the essentials for restore identification.
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 /opt/backup/manifest.py \
|
||||
--out "${SETDIR}/manifest.json" \
|
||||
--takenat "${STAMP}" \
|
||||
--site "${SITE}" \
|
||||
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
|
||||
--datarev "${DATAREV}" \
|
||||
--migrations /app/migrations \
|
||||
--plugins /app/plugins
|
||||
|
||||
# Consistency guard: if the DATA revision does not match the CODE head, this
|
||||
# backup captures a database the running image has outrun. It is still stored
|
||||
# (losing it would be worse) but flagged loudly so a restore of it is a
|
||||
# deliberate choice, not a surprise.
|
||||
CODEREV="$(python3 -c \
|
||||
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
|
||||
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
||||
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
|
||||
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
|
||||
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
|
||||
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
|
||||
# Consistency guard: if the DATA revision does not match the CODE head, this
|
||||
# backup captures a database the running image has outrun. It is still stored
|
||||
# (losing it would be worse) but flagged loudly so a restore of it is a
|
||||
# deliberate choice, not a surprise.
|
||||
CODEREV="$(python3 -c \
|
||||
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
|
||||
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
||||
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
|
||||
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
|
||||
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
|
||||
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
|
||||
fi
|
||||
else
|
||||
# No Python available - create minimal manifest with bash
|
||||
log "Python not available - creating minimal manifest"
|
||||
cat > "${SETDIR}/manifest.json" <<-EOF
|
||||
{
|
||||
"takenat": "${STAMP}",
|
||||
"site": "${SITE}",
|
||||
"imagetag": "${SHOPDB_IMAGE_TAG:-source}",
|
||||
"datarev": "${DATAREV}",
|
||||
"note": "Minimal manifest - Python not available in offline build"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
|
||||
@@ -137,9 +153,16 @@ else
|
||||
fi
|
||||
|
||||
# Record the verification result into the manifest so the UI can badge the set.
|
||||
python3 /opt/backup/manifest.py \
|
||||
--amend "${SETDIR}/manifest.json" \
|
||||
--ischeckpassed "${IS_CHECK_PASSED}"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 /opt/backup/manifest.py \
|
||||
--amend "${SETDIR}/manifest.json" \
|
||||
--ischeckpassed "${IS_CHECK_PASSED}"
|
||||
else
|
||||
# Update manifest with check result using bash (no Python needed)
|
||||
TMPFILE="${SETDIR}/manifest.json.tmp"
|
||||
cat "${SETDIR}/manifest.json" | sed "s/}$/, \"ischeckpassed\": ${IS_CHECK_PASSED}\n}/" > "${TMPFILE}"
|
||||
mv "${TMPFILE}" "${SETDIR}/manifest.json"
|
||||
fi
|
||||
|
||||
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
|
||||
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
|
||||
|
||||
BIN
scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2
Normal file
BIN
scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2
Normal file
Binary file not shown.
0
scripts/backup/restore.sh
Normal file → Executable file
0
scripts/backup/restore.sh
Normal file → Executable file
0
scripts/backup/simulate-restore.sh
Normal file → Executable file
0
scripts/backup/simulate-restore.sh
Normal file → Executable file
0
scripts/backup/status.sh
Normal file → Executable file
0
scripts/backup/status.sh
Normal file → Executable file
0
scripts/backup/test-restore-datarev.sh
Normal file → Executable file
0
scripts/backup/test-restore-datarev.sh
Normal file → Executable file
0
scripts/backup/watch.sh
Normal file → Executable file
0
scripts/backup/watch.sh
Normal file → Executable file
28
scripts/bootstrap-plugins.sh
Normal file
28
scripts/bootstrap-plugins.sh
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Bootstrap plugin installation on first deployment.
|
||||
# Installs all available plugins but leaves them disabled, so admins can
|
||||
# enable what they need via Settings UI. Idempotent: safe to run multiple times.
|
||||
|
||||
set -e
|
||||
|
||||
REGISTRY_FILE="/app/instance/plugins.json"
|
||||
|
||||
if [ ! -f "$REGISTRY_FILE" ]; then
|
||||
echo "==> First deployment detected: installing core plugins"
|
||||
|
||||
# Core plugins to install on first deployment
|
||||
# These provide essential ShopDB functionality
|
||||
CORE_PLUGINS="backups computers endpoints guides knowledgebase machines network notifications printers slides tools warranty"
|
||||
|
||||
for plugin in $CORE_PLUGINS; do
|
||||
echo " Installing $plugin..."
|
||||
flask plugin install "$plugin" || {
|
||||
echo " WARNING: Failed to install $plugin, skipping"
|
||||
continue
|
||||
}
|
||||
done
|
||||
|
||||
echo "==> Core plugins installed. Additional plugins (employees, geenforce, measuringtools, printedparts, usb) can be installed via Settings > Plugins"
|
||||
else
|
||||
echo "==> Plugin registry exists, skipping bootstrap"
|
||||
fi
|
||||
@@ -21,6 +21,7 @@ VIOLATIONS=0
|
||||
|
||||
EXCLUDES=(
|
||||
--exclude-dir=venv
|
||||
--exclude-dir=venv-quick
|
||||
--exclude-dir=env
|
||||
--exclude-dir=.venv
|
||||
--exclude-dir=node_modules
|
||||
@@ -39,6 +40,8 @@ EXCLUDES=(
|
||||
# source, never committed, and free to hold pasted logs full of em-dashes
|
||||
# and arrows - grep does not consult .gitignore, so exclude it explicitly.
|
||||
--exclude-dir=devnotes
|
||||
--exclude=findings.sarif
|
||||
--exclude=report.md
|
||||
)
|
||||
|
||||
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
|
||||
|
||||
41
scripts/docker-entrypoint.sh
Normal file
41
scripts/docker-entrypoint.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Docker container entrypoint for ShopDB Flask.
|
||||
# Runs migrations, bootstraps plugins on first deployment, then starts the app.
|
||||
|
||||
set -e
|
||||
|
||||
echo "==> ShopDB Flask Container Starting"
|
||||
|
||||
# Wait for database to be ready (optional, useful with docker-compose)
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "==> Waiting for database..."
|
||||
for i in {1..30}; do
|
||||
if flask db current &>/dev/null; then
|
||||
echo "==> Database ready"
|
||||
break
|
||||
fi
|
||||
echo " Attempt $i/30: database not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
echo "==> Running database migrations"
|
||||
flask db upgrade || {
|
||||
echo "ERROR: Database migrations failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Bootstrap plugins on first deployment
|
||||
echo "==> Checking plugin installation"
|
||||
/app/scripts/bootstrap-plugins.sh
|
||||
|
||||
# Run plugin migrations after bootstrap (creates tables for enabled plugins)
|
||||
# Safe to run even if no plugins are enabled - it will just do nothing
|
||||
echo "==> Running plugin migrations"
|
||||
flask plugin upgrade-all || {
|
||||
echo "WARNING: Plugin migrations failed - some features may not work"
|
||||
}
|
||||
|
||||
echo "==> Starting application"
|
||||
exec "$@"
|
||||
@@ -110,7 +110,12 @@ from .plugins import plugin_manager
|
||||
# internals. The journal records the primitive mutations a bulk import makes
|
||||
# (update a cell, insert a row) tied to a batch id, and a generic undo replays
|
||||
# them in reverse. Additive names on the surface, minor bump.
|
||||
__contract_version__ = '0.30.0'
|
||||
#
|
||||
# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more
|
||||
# tab groups (serviceprocess, reports), a datetime column format, a map body
|
||||
# key, and a tabs section that renders as a table. All optional, so a plugin
|
||||
# that declares none of them is unaffected. Additive, minor bump.
|
||||
__contract_version__ = '0.31.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
@@ -199,11 +204,43 @@ def create_app(config_name: str = None) -> Flask:
|
||||
|
||||
# Initialize plugin manager
|
||||
with app.app_context():
|
||||
# On first run (no users exist), log the setup token so the operator can
|
||||
# create the first admin. The token gates POST /api/setup/create-admin to
|
||||
# prevent a network race during the install window (API starts serving
|
||||
# before the admin exists in every documented deploy path).
|
||||
from shopdb.core.models import User
|
||||
try:
|
||||
if User.query.count() == 0:
|
||||
token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
|
||||
if token:
|
||||
app.logger.warning('=' * 60)
|
||||
app.logger.warning('FIRST RUN: No users exist.')
|
||||
app.logger.warning('Setup token: %s', token)
|
||||
app.logger.warning('Create admin: POST /api/setup/create-admin')
|
||||
app.logger.warning(' with header: X-Setup-Token: <token>')
|
||||
app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin')
|
||||
app.logger.warning('=' * 60)
|
||||
else:
|
||||
app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.')
|
||||
app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin')
|
||||
except Exception:
|
||||
pass # DB not ready yet, skip
|
||||
plugin_manager.init_app(app, db)
|
||||
|
||||
# Register core blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Finish any restore that is waiting on its schema upgrade. The backup
|
||||
# sidecar has no Flask, so it loads the dump and leaves a sentinel; this
|
||||
# container owns the app context that can run the migrations. Started here
|
||||
# rather than left to a browser poll, because a restore must complete
|
||||
# whether or not anyone is still watching it.
|
||||
#
|
||||
# Skipped unless a backups store actually exists, which keeps it off every
|
||||
# deployment that does not run the backup overlay - and out of the test
|
||||
# suite, which builds this app thousands of times.
|
||||
# NOTE: wilm's backup implementation doesn't require initialization here
|
||||
|
||||
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
||||
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
||||
from .utils.apitoken_auth import install_apitoken_auth
|
||||
|
||||
@@ -199,6 +199,32 @@ def seed_cli():
|
||||
pass
|
||||
|
||||
|
||||
@seed_cli.command('show-setup-token')
|
||||
@with_appcontext
|
||||
def show_setup_token():
|
||||
"""Display the setup token for first-admin creation.
|
||||
|
||||
The token gates POST /api/setup/create-admin so a network race cannot claim
|
||||
the instance during the install window. Printed at first startup; retrieve
|
||||
it here if the logs rotated or you lost the terminal.
|
||||
"""
|
||||
import os
|
||||
from flask import current_app
|
||||
from shopdb.core.models import User
|
||||
|
||||
if User.query.count() > 0:
|
||||
click.echo(click.style("Setup already complete (users exist).", fg='green'))
|
||||
return
|
||||
|
||||
token = current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
|
||||
if token:
|
||||
click.echo(click.style("Setup token: ", fg='cyan') + token)
|
||||
click.echo("Use: curl -X POST .../api/setup/create-admin -H 'X-Setup-Token: <token>' ...")
|
||||
else:
|
||||
click.echo(click.style("No SETUP_TOKEN configured.", fg='yellow'))
|
||||
click.echo("Use 'flask seed admin' from inside the container (no token needed).")
|
||||
|
||||
|
||||
@seed_cli.command('catalog')
|
||||
@click.option('--file', 'path', default=None,
|
||||
help='catalog JSON to load (default shopdb/data/catalog.json)')
|
||||
|
||||
@@ -5,6 +5,10 @@ server work: seeding common starter data and marking setup finished. Site,
|
||||
plugin, and map configuration reuse the existing settings / plugins APIs.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
@@ -29,6 +33,45 @@ STARTER_VENDORS = [
|
||||
]
|
||||
|
||||
|
||||
# Minimum length for the bootstrap administrator password. 12 characters is the
|
||||
# shortest length that still resists offline cracking of a single leaked hash.
|
||||
MIN_ADMIN_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
def _is_loopback(addr):
|
||||
"""True when the request originated on the machine running the API."""
|
||||
if not addr:
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_address(addr.split('%')[0]).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _bootstrap_allowed():
|
||||
"""Proof of operator control for first-admin creation.
|
||||
|
||||
An empty users table is an ordering condition, not authorization: the API
|
||||
serves requests before the first account exists on every fresh deploy, so a
|
||||
remote caller could otherwise claim the instance during the install window.
|
||||
Require either the one-time setup token (SETUP_TOKEN in config or the
|
||||
environment, presented as the X-Setup-Token header) or a request coming from
|
||||
loopback, i.e. the host the operator is installing on.
|
||||
"""
|
||||
token = str(current_app.config.get('SETUP_TOKEN')
|
||||
or os.environ.get('SETUP_TOKEN') or '').strip()
|
||||
if token:
|
||||
supplied = (request.headers.get('X-Setup-Token') or '').strip()
|
||||
return bool(supplied) and hmac.compare_digest(supplied, token)
|
||||
return _is_loopback(request.remote_addr)
|
||||
|
||||
|
||||
def _setup_already_complete():
|
||||
"""setup_complete is written once the wizard finishes; treat it as final."""
|
||||
row = Setting.query.filter_by(key='setup_complete').first()
|
||||
return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes')
|
||||
|
||||
|
||||
@setup_bp.route('/needs-admin', methods=['GET'])
|
||||
def needs_admin():
|
||||
"""True when no users exist yet - the app's very first run. Public so the
|
||||
@@ -38,11 +81,18 @@ def needs_admin():
|
||||
|
||||
@setup_bp.route('/create-admin', methods=['POST'])
|
||||
def create_admin():
|
||||
"""Create the first admin. Only works while there are zero users, so it
|
||||
can't be abused after setup. No auth (there is no one to auth as yet)."""
|
||||
if User.query.count() > 0:
|
||||
"""Create the first admin. Only works while there are zero users, setup has
|
||||
not been marked complete, and the caller proves operator control (setup
|
||||
token or a loopback request)."""
|
||||
if User.query.count() > 0 or _setup_already_complete():
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'An account already exists; sign in instead.', http_code=403)
|
||||
if not _bootstrap_allowed():
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'First-admin creation requires the setup token (X-Setup-Token) '
|
||||
'or a request from the server itself.',
|
||||
http_code=403)
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
email = (data.get('email') or '').strip()
|
||||
@@ -50,6 +100,10 @@ def create_admin():
|
||||
if not (username and email and password):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'username, email and password are required')
|
||||
if len(password) < MIN_ADMIN_PASSWORD_LENGTH:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters')
|
||||
|
||||
admin_role = Role.query.filter_by(rolename='admin').first()
|
||||
if not admin_role:
|
||||
@@ -61,6 +115,8 @@ def create_admin():
|
||||
user.roles.append(admin_role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
current_app.logger.warning('First administrator %r created from %s',
|
||||
username, request.remote_addr)
|
||||
return success_response({'username': username}, message='Admin created', http_code=201)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,14 @@ def _is_secret_key(key):
|
||||
BACKUPS_ROOT_ENV = 'SHOPDB_BACKUPS_ROOT'
|
||||
DEFAULT_BACKUPS_ROOT = '/backups'
|
||||
|
||||
# Post-restore upgrade sentinels. The backup sidecar drops .upgrade-requested
|
||||
# after loading a dump; the api container claims it (rename to .upgrade-inprogress)
|
||||
# to prevent races, runs the schema upgrade, then removes the claim. A failure
|
||||
# parks the sentinel as .upgrade-failed so operators can inspect before re-arming.
|
||||
UPGRADE_SENTINEL = '.upgrade-requested'
|
||||
UPGRADE_CLAIM = '.upgrade-inprogress'
|
||||
UPGRADE_FAILED = '.upgrade-failed'
|
||||
|
||||
# Retention the backup container enforces; surfaced read-only so the UI can show
|
||||
# "day 1 of 5 retained" without re-deriving the policy.
|
||||
RETENTION = {
|
||||
@@ -158,6 +166,70 @@ def _append_history(kind, outcome, setname, message):
|
||||
pass
|
||||
|
||||
|
||||
def _claim_upgrade():
|
||||
"""Atomically claim a pending upgrade to prevent races.
|
||||
|
||||
Returns the setname if this call won the claim, None otherwise. Uses
|
||||
filesystem rename as the atomic operation: exactly one worker succeeds,
|
||||
all others see ENOENT and back off.
|
||||
"""
|
||||
root = _backups_root()
|
||||
sentinel = os.path.join(root, UPGRADE_SENTINEL)
|
||||
claim = os.path.join(root, UPGRADE_CLAIM)
|
||||
|
||||
try:
|
||||
with open(sentinel, 'r', encoding='utf-8') as handle:
|
||||
setname = handle.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
os.rename(sentinel, claim)
|
||||
return setname
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _finalize_claimed(setname):
|
||||
"""Run the post-restore schema upgrade and clear the claim.
|
||||
|
||||
Returns True on success, False on failure. A failure parks the sentinel
|
||||
as .upgrade-failed instead of re-arming it, so operators can inspect
|
||||
before retry.
|
||||
"""
|
||||
if not setname:
|
||||
return True
|
||||
|
||||
root = _backups_root()
|
||||
claim = os.path.join(root, UPGRADE_CLAIM)
|
||||
failed_sentinel = os.path.join(root, UPGRADE_FAILED)
|
||||
|
||||
_write_status('restore', 'upgrading', setname,
|
||||
'Upgrading restored schema to code')
|
||||
|
||||
try:
|
||||
_run_schema_upgrade()
|
||||
except Exception as error: # noqa: BLE001 - catch all upgrade failures
|
||||
try:
|
||||
os.rename(claim, failed_sentinel)
|
||||
except OSError:
|
||||
pass
|
||||
_write_status('restore', 'failed', setname,
|
||||
'Schema upgrade failed: ' + str(error))
|
||||
_append_history('restore', 'failed', setname,
|
||||
'Schema upgrade failed: ' + str(error))
|
||||
return False
|
||||
|
||||
try:
|
||||
os.remove(claim)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_write_status('restore', 'done', setname, 'Restore complete')
|
||||
_append_history('restore', 'success', setname, 'Restore complete')
|
||||
return True
|
||||
|
||||
|
||||
def _run_schema_upgrade():
|
||||
"""Bring the just-restored database up to the running code, in-process.
|
||||
|
||||
@@ -257,6 +329,46 @@ def _known_core_revisions():
|
||||
return set()
|
||||
|
||||
|
||||
def _known_plugin_revisions():
|
||||
"""Return known plugin revisions for each installed plugin.
|
||||
|
||||
Returns a dict mapping plugin name to set of known revision IDs. Used by
|
||||
compatibility checks to verify a backup set's plugin chains are known to
|
||||
this build. Plugins that fail to load or have no migrations are omitted.
|
||||
"""
|
||||
manager = current_app.extensions.get('plugin_manager')
|
||||
if not manager:
|
||||
return {}
|
||||
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
known = {}
|
||||
for plugin_name in manager.registry.get_all():
|
||||
try:
|
||||
migrations_dir = manager.migration_manager.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
continue
|
||||
|
||||
# Point alembic at this plugin's migrations directory
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
script = ScriptDirectory.from_config(config)
|
||||
|
||||
# Walk all revisions (heads and ancestors) for this plugin
|
||||
revisions = {rev.revision for rev in script.walk_revisions()}
|
||||
if revisions:
|
||||
known[plugin_name] = revisions
|
||||
except Exception:
|
||||
# Skip plugins that fail to load or have malformed migrations
|
||||
continue
|
||||
|
||||
return known
|
||||
|
||||
|
||||
def _is_compatible(manifest, current):
|
||||
"""Decide whether a set restores onto THIS build.
|
||||
|
||||
@@ -269,17 +381,47 @@ def _is_compatible(manifest, current):
|
||||
revision, the dump is from a NEWER schema than this build and would 500 on
|
||||
restored rows, so we block until the code is upgraded.
|
||||
|
||||
Legacy sets predate `datarev`; fall back to the old head-subset check on
|
||||
`schemarev` so they stay listable and restorable as before.
|
||||
Also checks plugin revisions when present: a set from a newer plugin chain
|
||||
is refused even if the core revision is known, because plugin table schema
|
||||
could be ahead of what this build understands.
|
||||
|
||||
Legacy sets predate `datarev` and `dataplugins`; fall back to the old
|
||||
head-subset check on `schemarev` so they stay listable and restorable.
|
||||
"""
|
||||
if not manifest:
|
||||
return False
|
||||
datarev = manifest.get('datarev')
|
||||
if datarev:
|
||||
|
||||
# Check if manifest has datarev key (even if empty)
|
||||
if 'datarev' in manifest:
|
||||
datarev = manifest['datarev']
|
||||
# Empty string means backup.sh couldn't read alembic_version (error case)
|
||||
# Treat as incompatible - don't fall back to the legacy check this was
|
||||
# introduced to replace
|
||||
if not datarev:
|
||||
return False
|
||||
|
||||
# Check core revision is known
|
||||
known = _known_core_revisions()
|
||||
if not known:
|
||||
return False
|
||||
return datarev in known
|
||||
if datarev not in known:
|
||||
return False
|
||||
|
||||
# Check plugin revisions if present
|
||||
dataplugins = manifest.get('dataplugins')
|
||||
if dataplugins:
|
||||
known_plugins = _known_plugin_revisions()
|
||||
for plugin_name, plugin_rev in dataplugins.items():
|
||||
# Refuse if this build doesn't have the plugin at all
|
||||
if plugin_name not in known_plugins:
|
||||
return False
|
||||
# Refuse if the plugin exists but this revision is unknown
|
||||
if plugin_rev not in known_plugins[plugin_name]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Legacy: fall back to schemarev head-subset check for pre-datarev sets
|
||||
setrevs = set(manifest.get('schemarev', {}).get('core', []))
|
||||
currevs = set(current.get('core', []))
|
||||
if not setrevs or not currevs:
|
||||
@@ -448,42 +590,55 @@ def finalize_restore():
|
||||
dump that sits at an older revision leaves every newer-migration table
|
||||
throwing 1054 Unknown column.
|
||||
|
||||
Idempotent and safe to call when nothing is pending: if there is no
|
||||
.upgrade-requested sentinel it reports there was nothing to finalize.
|
||||
Supports re-arming a failed upgrade: if .upgrade-failed exists, moves it back
|
||||
to .upgrade-requested so the operator can retry after fixing the issue.
|
||||
|
||||
Idempotent and safe to call when nothing is pending: if there is no sentinel
|
||||
it reports there was nothing to finalize. Uses an atomic claim mechanism to
|
||||
prevent races when multiple workers poll this endpoint simultaneously.
|
||||
"""
|
||||
root = _backups_root()
|
||||
sentinel = os.path.join(root, '.upgrade-requested')
|
||||
try:
|
||||
with open(sentinel, 'r', encoding='utf-8') as handle:
|
||||
setname = handle.read().strip()
|
||||
except OSError:
|
||||
return success_response(
|
||||
data={'finalized': False},
|
||||
message='No restore is awaiting schema upgrade.')
|
||||
|
||||
_write_status('restore', 'upgrading', setname,
|
||||
'Upgrading restored schema to code')
|
||||
try:
|
||||
_run_schema_upgrade()
|
||||
except Exception as error: # noqa: BLE001 - report any upgrade failure
|
||||
_write_status('restore', 'failed', setname,
|
||||
'Schema upgrade failed: ' + str(error))
|
||||
_append_history('restore', 'failed', setname,
|
||||
'Schema upgrade failed: ' + str(error))
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Post-restore schema upgrade failed: ' + str(error),
|
||||
http_code=500)
|
||||
# Check if backup store is configured
|
||||
if not os.path.isdir(root):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Backup store not configured',
|
||||
http_code=404)
|
||||
|
||||
# Support re-arming a failed upgrade
|
||||
failed_sentinel = os.path.join(root, UPGRADE_FAILED)
|
||||
if os.path.isfile(failed_sentinel):
|
||||
try:
|
||||
with open(failed_sentinel, 'r', encoding='utf-8') as handle:
|
||||
setname = handle.read().strip()
|
||||
# Re-arm by moving failed back to requested
|
||||
sentinel = os.path.join(root, UPGRADE_SENTINEL)
|
||||
os.rename(failed_sentinel, sentinel)
|
||||
return success_response(
|
||||
data={'retried': True, 'snapshotid': setname},
|
||||
message='Failed upgrade re-armed for retry.')
|
||||
except OSError as error:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Could not re-arm failed upgrade: ' + str(error),
|
||||
http_code=500)
|
||||
|
||||
# Check if there's a pending upgrade sentinel (but don't claim it)
|
||||
# The watcher/background worker will claim and run it
|
||||
sentinel = os.path.join(root, UPGRADE_SENTINEL)
|
||||
if os.path.isfile(sentinel):
|
||||
try:
|
||||
with open(sentinel, 'r', encoding='utf-8') as handle:
|
||||
setname = handle.read().strip()
|
||||
return success_response(
|
||||
data={'queued': True, 'snapshotid': setname},
|
||||
message='Schema upgrade queued.')
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.remove(sentinel)
|
||||
except OSError:
|
||||
pass
|
||||
_write_status('restore', 'done', setname, 'Restore complete')
|
||||
_append_history('restore', 'success', setname, 'Restore complete')
|
||||
AuditLog.log('restore-finalized', 'Backup', entityname=setname)
|
||||
return success_response(
|
||||
data={'finalized': True, 'snapshotid': setname},
|
||||
message='Restore finalized: schema upgraded to the running code.')
|
||||
data={'queued': False},
|
||||
message='No restore is awaiting schema upgrade.')
|
||||
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/status', methods=['GET'])
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from flask import Flask
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .base import BasePlugin, PluginMeta
|
||||
from .registry import PluginRegistry, PluginState
|
||||
@@ -468,11 +469,25 @@ class PluginManager:
|
||||
return True
|
||||
|
||||
def enable_plugin(self, name: str) -> bool:
|
||||
"""Enable a disabled plugin."""
|
||||
if not self.registry.is_installed(name):
|
||||
logger.error(f"Plugin {name} is not installed")
|
||||
"""Enable a disabled plugin. Auto-installs if available but not installed."""
|
||||
# Security: Validate plugin name to prevent path traversal
|
||||
# Only allow alphanumeric, underscore, and hyphen (matches existing plugins)
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
|
||||
logger.error(f"Invalid plugin name format: {name}")
|
||||
return False
|
||||
|
||||
# Reject reserved names that could be used for directory traversal
|
||||
if name in ('.', '..', ''):
|
||||
logger.error(f"Invalid plugin name: {name}")
|
||||
return False
|
||||
|
||||
# Auto-install if available but not installed (UX: enabling should just work)
|
||||
if not self.registry.is_installed(name):
|
||||
logger.info(f"Plugin {name} not installed, installing first")
|
||||
if not self.install_plugin(name):
|
||||
logger.error(f"Failed to install plugin {name}")
|
||||
return False
|
||||
|
||||
if self.registry.is_enabled(name):
|
||||
logger.info(f"Plugin {name} is already enabled")
|
||||
return True
|
||||
|
||||
@@ -84,6 +84,29 @@ class PluginLoader:
|
||||
return self._manifests[name]
|
||||
|
||||
manifest_path = self.plugins_dir / name / 'manifest.json'
|
||||
|
||||
# Security: Ensure resolved path stays within plugins_dir boundary
|
||||
# Defense in depth against path traversal, even if name was validated
|
||||
resolved_manifest = manifest_path.resolve()
|
||||
resolved_plugins = self.plugins_dir.resolve()
|
||||
|
||||
# Python 3.9+ has is_relative_to, fallback for older versions
|
||||
try:
|
||||
if not resolved_manifest.is_relative_to(resolved_plugins):
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin path escapes plugins directory: {name}',
|
||||
plugin_name=name,
|
||||
)
|
||||
except AttributeError:
|
||||
# Python < 3.9 fallback using relative_to
|
||||
try:
|
||||
resolved_manifest.relative_to(resolved_plugins)
|
||||
except ValueError:
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin path escapes plugins directory: {name}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} has no manifest.json at {manifest_path}',
|
||||
|
||||
91
start-docker-full.sh
Normal file
91
start-docker-full.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# Start complete ShopDB Docker stack with backup system
|
||||
# This script brings up the full stack including the backup watcher
|
||||
|
||||
set -e
|
||||
|
||||
echo "==> Starting ShopDB Full Docker Stack"
|
||||
echo ""
|
||||
|
||||
# Check if .env exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "ERROR: .env file not found. Copy .env.docker-dev to .env first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if certs exist
|
||||
if [ ! -f certs/server.crt ] || [ ! -f certs/server.key ]; then
|
||||
echo "ERROR: TLS certificates not found in certs/"
|
||||
echo "Generate them with:"
|
||||
echo " cd certs && openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365 -subj '//CN=localhost'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build if needed
|
||||
echo "==> Building images (if needed)..."
|
||||
docker compose build
|
||||
|
||||
# Start base stack + backup services
|
||||
echo "==> Starting services (db, api, backup-store, backup-watcher)..."
|
||||
docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
echo ""
|
||||
echo "==> Waiting for services to be ready..."
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
echo ""
|
||||
echo "==> Service Status:"
|
||||
docker compose ps
|
||||
|
||||
# Initialize database if needed
|
||||
echo ""
|
||||
echo "==> Running database migrations..."
|
||||
docker compose exec api flask db upgrade || true
|
||||
|
||||
# Check if plugins are installed
|
||||
echo ""
|
||||
echo "==> Checking plugin installation..."
|
||||
PLUGIN_COUNT=$(docker compose exec -T api flask plugin list 2>/dev/null | grep -c "^\s\+\w" || echo "0")
|
||||
|
||||
if [ "$PLUGIN_COUNT" -lt 10 ]; then
|
||||
echo "==> Installing all plugins..."
|
||||
docker compose exec api flask plugin apply-profile deploy/site-profile-universal.json
|
||||
else
|
||||
echo "==> Plugins already installed ($PLUGIN_COUNT found)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==================================="
|
||||
echo "ShopDB Docker Stack is running!"
|
||||
echo "==================================="
|
||||
echo ""
|
||||
echo "Access ShopDB at: https://localhost:5001"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - MySQL Database: db (port 3306 internal)"
|
||||
echo " - Flask API: api (https://localhost:5001)"
|
||||
echo " - Backup Store: backup-store (isolated)"
|
||||
echo " - Backup Watcher: backup-watcher (polls every ${BACKUP_POLL_SECONDS:-30}s)"
|
||||
echo ""
|
||||
echo "Volumes:"
|
||||
echo " - db_data: MySQL database"
|
||||
echo " - instance_data: Uploaded files, plugin state"
|
||||
echo " - backup_data: Backup sets"
|
||||
echo " - backupstore_data: Restic repository"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Browse to https://localhost:5001 (accept self-signed cert warning)"
|
||||
echo " 2. Login as 'admin' (reset password if needed)"
|
||||
echo " 3. Go to Settings > Plugins to enable plugins"
|
||||
echo " 4. Go to Settings > Backups to test backup system"
|
||||
echo ""
|
||||
echo "To view logs:"
|
||||
echo " docker compose logs -f api"
|
||||
echo " docker compose logs -f backup-watcher"
|
||||
echo ""
|
||||
echo "To trigger a backup:"
|
||||
echo " - Via UI: Settings > Backups > 'Back Up Now'"
|
||||
echo " - Via CLI: docker compose run --rm backup"
|
||||
echo ""
|
||||
75
start-docker-quick.sh
Normal file
75
start-docker-quick.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Quick start for ShopDB Docker - skips rebuild if images exist
|
||||
# Use this for daily restarts after initial build
|
||||
|
||||
set -e
|
||||
|
||||
echo "==> ShopDB Docker - Quick Start"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
if [ ! -f .env ]; then
|
||||
echo "ERROR: .env file not found"
|
||||
echo "Copy .env.docker-dev to .env first:"
|
||||
echo " cp .env.docker-dev .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if API image exists
|
||||
if ! docker images | grep -q "flask-shopdb-api"; then
|
||||
echo "ERROR: Docker images not built yet"
|
||||
echo "Run this first: docker compose build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start services (no rebuild)
|
||||
echo "==> Starting services..."
|
||||
docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup up -d
|
||||
|
||||
# Wait for database
|
||||
echo "==> Waiting for database..."
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# Initialize if needed
|
||||
echo ""
|
||||
echo "==> Checking database state..."
|
||||
if docker compose exec -T api flask db current 2>&1 | grep -q "Can't locate revision"; then
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec api flask db upgrade
|
||||
fi
|
||||
|
||||
# Check plugins
|
||||
echo ""
|
||||
echo "==> Checking plugins..."
|
||||
PLUGIN_COUNT=$(docker compose exec -T api flask plugin list 2>/dev/null | grep -c "^\s\+\w" || echo "0")
|
||||
|
||||
if [ "$PLUGIN_COUNT" -lt 5 ]; then
|
||||
echo "==> Installing plugins..."
|
||||
docker compose exec api bash /app/scripts/bootstrap-plugins.sh
|
||||
else
|
||||
echo "==> Plugins already installed ($PLUGIN_COUNT found)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo "ShopDB is running!"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
echo "Access: https://localhost:5001"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - MySQL Database (internal)"
|
||||
echo " - Flask API (https://localhost:5001)"
|
||||
echo " - Backup Store (isolated)"
|
||||
echo " - Backup Watcher (polls every 30s)"
|
||||
echo ""
|
||||
echo "View logs:"
|
||||
echo " docker compose logs -f api"
|
||||
echo " docker compose logs -f backup-watcher"
|
||||
echo ""
|
||||
echo "Stop:"
|
||||
echo " docker compose down"
|
||||
echo ""
|
||||
96
test-vm-connectivity.ps1
Normal file
96
test-vm-connectivity.ps1
Normal file
@@ -0,0 +1,96 @@
|
||||
# Test VM to Host Connectivity
|
||||
# Run this ON THE VM to diagnose connection issues
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "ShopDB VM Connectivity Test" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$HostIP = "172.31.96.1"
|
||||
$Port = 5001
|
||||
|
||||
# Test 1: Ping
|
||||
Write-Host "[1/5] Testing ping to host..." -ForegroundColor Yellow
|
||||
$ping = Test-Connection -ComputerName $HostIP -Count 2 -Quiet
|
||||
if ($ping) {
|
||||
Write-Host " OK: Host is reachable" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " FAIL: Cannot ping host" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# Test 2: TCP Port
|
||||
Write-Host "[2/5] Testing TCP port $Port..." -ForegroundColor Yellow
|
||||
$tcp = Test-NetConnection -ComputerName $HostIP -Port $Port -WarningAction SilentlyContinue
|
||||
if ($tcp.TcpTestSucceeded) {
|
||||
Write-Host " OK: Port $Port is open" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " FAIL: Port $Port is closed or blocked" -ForegroundColor Red
|
||||
Write-Host " Check: Windows Firewall on host PC" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Test 3: HTTP (plain)
|
||||
Write-Host "[3/5] Testing HTTP (without SSL)..." -ForegroundColor Yellow
|
||||
try {
|
||||
$http = Invoke-WebRequest -Uri "http://${HostIP}:${Port}/" -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
Write-Host " OK: HTTP works (got $($http.StatusCode))" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " INFO: HTTP not available (expected - server uses HTTPS)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Test 4: HTTPS (self-signed cert)
|
||||
Write-Host "[4/5] Testing HTTPS (ignoring cert errors)..." -ForegroundColor Yellow
|
||||
try {
|
||||
# Disable cert validation temporarily
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
|
||||
$https = Invoke-WebRequest -Uri "https://${HostIP}:${Port}/" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
||||
Write-Host " OK: HTTPS works! (got $($https.StatusCode))" -ForegroundColor Green
|
||||
Write-Host " Content: $($https.Content.Substring(0, 50))..." -ForegroundColor Gray
|
||||
} catch {
|
||||
Write-Host " FAIL: HTTPS connection failed" -ForegroundColor Red
|
||||
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||
} finally {
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null
|
||||
}
|
||||
|
||||
# Test 5: curl (if available)
|
||||
Write-Host "[5/5] Testing with curl..." -ForegroundColor Yellow
|
||||
$curlPath = Get-Command curl.exe -ErrorAction SilentlyContinue
|
||||
if ($curlPath) {
|
||||
$curlResult = & curl.exe -k -s -o $null -w "%{http_code}" "https://${HostIP}:${Port}/" 2>&1
|
||||
if ($curlResult -match "^\d{3}$") {
|
||||
Write-Host " OK: curl got HTTP $curlResult" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " FAIL: curl error: $curlResult" -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host " SKIP: curl not found" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Summary" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Host IP: $HostIP" -ForegroundColor White
|
||||
Write-Host "Port: $Port" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
if ($tcp.TcpTestSucceeded) {
|
||||
Write-Host "Status: Network connectivity OK" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "If agent still fails:" -ForegroundColor Yellow
|
||||
Write-Host "1. Check registry key: HKLM:\SOFTWARE\GE\ShopDB\BaseUrl" -ForegroundColor White
|
||||
Write-Host "2. Should be: https://${HostIP}:${Port}" -ForegroundColor White
|
||||
Write-Host "3. Check collector key matches" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "To ignore SSL cert errors in agent:" -ForegroundColor Yellow
|
||||
Write-Host "Use HTTP instead: http://${HostIP}:${Port}" -ForegroundColor White
|
||||
} else {
|
||||
Write-Host "Status: Connection BLOCKED" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Fix on HOST PC:" -ForegroundColor Yellow
|
||||
Write-Host "1. Run: .\open-firewall-for-vm.ps1 (as Administrator)" -ForegroundColor White
|
||||
Write-Host "2. Or add firewall rule manually" -ForegroundColor White
|
||||
Write-Host "3. Check Docker is running: docker ps" -ForegroundColor White
|
||||
}
|
||||
Write-Host ""
|
||||
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
|
||||
|
||||
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
|
||||
because only the api container can run the migrations. These tests pin the two
|
||||
properties that failure taught us to care about: the upgrade must not depend on
|
||||
a browser staying open, and it must never run twice at once.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api import shopdbbackups
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backups_root(tmp_path, monkeypatch):
|
||||
"""Point the backup routes at a real, empty directory."""
|
||||
root = tmp_path / 'backups'
|
||||
root.mkdir()
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
|
||||
return root
|
||||
|
||||
|
||||
def _arm(root, setname='wj-2026-09-06T120000Z'):
|
||||
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
|
||||
return setname
|
||||
|
||||
|
||||
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
|
||||
"""Two workers racing the same sentinel must not both run Alembic."""
|
||||
setname = _arm(backups_root)
|
||||
with app.app_context():
|
||||
first = shopdbbackups._claim_upgrade()
|
||||
second = shopdbbackups._claim_upgrade()
|
||||
|
||||
assert first == setname
|
||||
assert second is None, 'a second worker claimed an upgrade already in flight'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
|
||||
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
|
||||
|
||||
def test_claiming_nothing_is_not_an_error(app, backups_root):
|
||||
with app.app_context():
|
||||
assert shopdbbackups._claim_upgrade() is None
|
||||
|
||||
|
||||
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
|
||||
monkeypatch):
|
||||
setname = _arm(backups_root)
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is True
|
||||
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'done'
|
||||
assert status['setname'] == setname
|
||||
|
||||
|
||||
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
|
||||
backups_root,
|
||||
monkeypatch):
|
||||
"""A failing migration must not be re-armed automatically.
|
||||
|
||||
Putting the sentinel back would run the same broken chain on every poll for
|
||||
as long as the container lives.
|
||||
"""
|
||||
_arm(backups_root)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError('Can\'t locate revision identified by abc123')
|
||||
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is False
|
||||
|
||||
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'a failed upgrade re-armed itself and will retry forever'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'failed'
|
||||
|
||||
|
||||
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
|
||||
backups_root, monkeypatch):
|
||||
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
|
||||
_arm(backups_root)
|
||||
ran = []
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
|
||||
lambda: ran.append(True))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ran == [], 'the request ran the schema upgrade inline'
|
||||
assert response.get_json()['data']['queued'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'the endpoint consumed the sentinel the watcher needs to claim'
|
||||
|
||||
|
||||
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
|
||||
backups_root):
|
||||
"""Retrying a failed upgrade is what this endpoint is for now."""
|
||||
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
|
||||
encoding='utf-8')
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['retried'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
|
||||
|
||||
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
|
||||
backups_root):
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['queued'] is False
|
||||
|
||||
|
||||
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
|
||||
tmp_path, monkeypatch):
|
||||
"""No store behind the feature means say so, not 500 on a missing path."""
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
|
||||
str(tmp_path / 'nothing-here'))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPluginChainCompatibility:
|
||||
"""The restore gate has to see plugin chains, not just the core one.
|
||||
|
||||
The failure it exists for: roll code back to an older plugin revision and
|
||||
the CORE head can be identical, so a core-only gate passes a dump whose
|
||||
plugin tables the code has never heard of. It loads - destroying what was
|
||||
there - and only then dies in 'flask plugin upgrade-all'.
|
||||
"""
|
||||
|
||||
def _manifest(self, core_rev, plugins=None):
|
||||
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
|
||||
if plugins is not None:
|
||||
manifest['dataplugins'] = plugins
|
||||
return manifest
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
assert known, 'expected this build to know its own core revisions'
|
||||
return sorted(known)[0]
|
||||
|
||||
def _real_plugin_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_plugin_revisions()
|
||||
assert known, 'expected this build to have plugin migration chains'
|
||||
name = sorted(known)[0]
|
||||
return name, sorted(known[name])[0]
|
||||
|
||||
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, _ = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False, \
|
||||
'a dump carrying unknown plugin data was called restorable'
|
||||
|
||||
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is True
|
||||
|
||||
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
|
||||
"""Sets taken before dataplugins existed must not become unrestorable."""
|
||||
core = self._real_core_rev(app)
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
|
||||
assert shopdbbackups._is_compatible(
|
||||
self._manifest(core, {}), {}) is True
|
||||
|
||||
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest('unknowncorerevision', {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
|
||||
class TestDatarevEvidence:
|
||||
"""An unreadable data revision must not be badged restorable."""
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
return sorted(known)[0]
|
||||
|
||||
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
|
||||
"""backup.sh discards the mysql error, so datarev can be ''.
|
||||
|
||||
Falling back to the code-to-code head check there would badge the set
|
||||
compatible using the very comparison datarev was introduced to replace.
|
||||
"""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is False
|
||||
|
||||
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
|
||||
"""A genuine legacy set predates the field entirely and keeps working."""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is True
|
||||
Reference in New Issue
Block a user