Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions .github/workflows/deploy-mvp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Deploy MVP (Azure)

on:
push:
branches: [main]
branches: [main, azure-mvp-deploy]
workflow_dispatch:

concurrency:
Expand All @@ -11,6 +11,7 @@ concurrency:

env:
IMAGE_TAG: ${{ github.sha }}
ONEVO_APP_DIR: /opt/onevo/app

jobs:
build-push:
Expand Down Expand Up @@ -100,7 +101,8 @@ jobs:
--exclude node_modules \
--exclude dashboard/dist \
--exclude connector/dist/build \
-e ssh ./ "${VM_USER}@${VM_HOST}:/opt/onevo/"
--exclude "installer-site/*.exe" \
-e ssh ./ "${VM_USER}@${VM_HOST}:${ONEVO_APP_DIR}/"

- name: Write production .env and deploy
env:
Expand All @@ -110,7 +112,7 @@ jobs:
DASHBOARD_IMAGE: ${{ needs.build-push.outputs.dashboard_image }}
CLOUD_AI_IMAGE: ${{ needs.build-push.outputs.cloud_ai_image }}
run: |
ssh "${VM_USER}@${VM_HOST}" "mkdir -p /opt/onevo/connector/dist"
ssh "${VM_USER}@${VM_HOST}" "mkdir -p ${ONEVO_APP_DIR}/installer-site ${ONEVO_APP_DIR}/connector/dist"
printf '%s\n' "${{ secrets.PRODUCTION_ENV }}" > /tmp/onevo.env
{
echo ""
Expand All @@ -121,8 +123,8 @@ jobs:
echo "ACR_USERNAME=${{ secrets.ACR_USERNAME }}"
echo "ACR_PASSWORD=${{ secrets.ACR_PASSWORD }}"
} >> /tmp/onevo.env
scp /tmp/onevo.env "${VM_USER}@${VM_HOST}:/opt/onevo/.env"
ssh "${VM_USER}@${VM_HOST}" "chmod +x /opt/onevo/infra/mvp/deploy.sh && /opt/onevo/infra/mvp/deploy.sh"
scp /tmp/onevo.env "${VM_USER}@${VM_HOST}:${ONEVO_APP_DIR}/.env"
ssh "${VM_USER}@${VM_HOST}" "chmod +x ${ONEVO_APP_DIR}/infra/mvp/deploy.sh && ONEVO_DIR=${ONEVO_APP_DIR} USE_GPU=false DEPLOY_MODE=acr ${ONEVO_APP_DIR}/infra/mvp/deploy.sh"

- name: Smoke test (public API)
env:
Expand Down Expand Up @@ -168,7 +170,7 @@ jobs:
pip install -r connector/requirements-build.txt
$url = "${{ secrets.BACKEND_PUBLIC_URL }}"
if (-not $url) { throw "Set BACKEND_PUBLIC_URL secret (https://api.yourdomain.example)" }
./scripts/build-installer.ps1 -BackendUrl $url
./scripts/build-installer.ps1 -BackendUrl $url -AllowHttp

- name: Upload installer artifact
uses: actions/upload-artifact@v4
Expand All @@ -188,5 +190,7 @@ jobs:
if (-not $exe) { throw "Installer EXE not found" }
$keyPath = "$env:RUNNER_TEMP/deploy_key"
Set-Content -Path $keyPath -Value $env:VM_SSH_KEY -NoNewline
scp -i $keyPath -o StrictHostKeyChecking=no $exe.FullName "${env:VM_USER}@${env:VM_HOST}:/opt/onevo/connector/dist/"
ssh -i $keyPath -o StrictHostKeyChecking=no "${env:VM_USER}@${env:VM_HOST}" "ls -la /opt/onevo/connector/dist/"
$appDir = "/opt/onevo/app"
scp -i $keyPath -o StrictHostKeyChecking=no $exe.FullName "${env:VM_USER}@${env:VM_HOST}:${appDir}/installer-site/"
scp -i $keyPath -o StrictHostKeyChecking=no $exe.FullName "${env:VM_USER}@${env:VM_HOST}:${appDir}/connector/dist/"
ssh -i $keyPath -o StrictHostKeyChecking=no "${env:VM_USER}@${env:VM_HOST}" "ls -la ${appDir}/installer-site/"
89 changes: 89 additions & 0 deletions Jenkinsfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
pipeline {
agent any

parameters {
string(name: 'VM_HOST', defaultValue: '20.193.69.220', description: 'Azure VM public IP')
string(name: 'VM_USER', defaultValue: 'azureuser', description: 'SSH user on VM')
string(name: 'BACKEND_URL', defaultValue: 'http://20.193.69.220:8081', description: 'Public API URL (installer bake + smoke)')
booleanParam(name: 'SKIP_INSTALLER', defaultValue: false, description: 'Skip Windows connector EXE build/upload')
booleanParam(name: 'SKIP_CI', defaultValue: false, description: 'Skip CI build/test stages')
booleanParam(name: 'USE_GPU', defaultValue: false, description: 'Include docker-compose.gpu.yml (requires NVIDIA on VM)')
}

environment {
VM_APP_DIR = '/opt/onevo/app'
}

stages {
stage('CI') {
when { expression { !params.SKIP_CI } }
parallel {
stage('Backend') {
steps {
dir('backend') {
bat 'dotnet restore Onevo.Api.csproj'
bat 'dotnet build Onevo.Api.csproj -c Release --no-restore'
}
}
}
stage('Dashboard') {
steps {
dir('dashboard') {
bat 'npm ci'
bat 'npm run build'
}
}
}
stage('Connector tests') {
steps {
dir('connector') {
bat '"C:\\Users\\Abdul Baasith\\AppData\\Local\\Python\\bin\\python.exe" -m pip install -r requirements.txt pytest'
bat 'set PYTHONPATH=.&& "C:\\Users\\Abdul Baasith\\AppData\\Local\\Python\\bin\\python.exe" -m pytest tests/ -q'
}
}
}
}
}

stage('Deploy to VM') {
steps {
script {
def extra = ''
if (params.SKIP_INSTALLER) { extra += ' -SkipInstaller' }
if (params.USE_GPU) { extra += ' -UseGpu' }
withCredentials([sshUserPrivateKey(
credentialsId: 'onevo-vm-ssh-key',
keyFileVariable: 'SSH_KEY',
usernameVariable: 'SSH_USER'
)]) {
bat """
powershell -ExecutionPolicy Bypass -File scripts/deploy-vm.ps1 ^
-VmHost ${params.VM_HOST} ^
-VmUser ${params.VM_USER} ^
-BackendUrl ${params.BACKEND_URL} ^
-SshKeyPath "${env.SSH_KEY}"${extra}
"""
}
}
}
}

stage('Smoke test') {
steps {
bat """
curl -sf ${params.BACKEND_URL}/api/health
curl -sf -o NUL -w "Dashboard HTTP %%{http_code}\\n" http://${params.VM_HOST}:4200/
"""
}
}
}

post {
success {
echo "ONEVO deploy succeeded — dashboard http://${params.VM_HOST}:4200"
}
failure {
echo 'Deploy failed — check Jenkins console and VM docker compose logs.'
}
}
}
3 changes: 2 additions & 1 deletion connector/app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def build_app(

@app.middleware("http")
async def admin_auth_middleware(request, call_next):
if request.url.path in ("/health",):
path = request.url.path
if path in ("/health",) or path.startswith("/setup"):
return await call_next(request)
if admin_token:
provided = request.headers.get("X-Admin-Token") or request.query_params.get("admin_token")
Expand Down
5 changes: 3 additions & 2 deletions connector/app/backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ def claim_setup_code(self, setup_code: str, name: str, version: str) -> tuple[st
)
if not r.ok:
try:
detail = r.json().get("error") or r.json().get("detail") or r.text
payload = r.json()
detail = payload.get("error") or payload.get("detail") or r.text
except Exception: # noqa: BLE001
detail = r.text
detail = r.text or f"claim failed ({r.status_code})"
raise RuntimeError(detail or f"claim failed ({r.status_code})")
data = r.json()
self.connector_id = data["connectorId"]
Expand Down
34 changes: 25 additions & 9 deletions connector/app/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel

from .backend_client import BackendClient
from .capture import validate_rtsp_stream
Expand All @@ -38,6 +39,11 @@ def parse_rtsp_urls(rtsp_text: str) -> list[str]:
WIZARD_ROUTE_PREFIX = "/setup"


class WizardClaimBody(BaseModel):
setupCode: str = ""
name: str = ""


def wizard_page_html(route_prefix: str = "") -> str:
"""HTML for the setup wizard; route_prefix is '' for standalone or '/setup' when mounted."""
prefix = route_prefix.rstrip("/")
Expand Down Expand Up @@ -197,8 +203,13 @@ def wizard_page_html(route_prefix: str = "") -> str:
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setupCode: code, name })
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || data.error || 'Claim failed');
const text = await r.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch { data = { detail: text || r.statusText }; }
if (!r.ok) {
const err = data.detail || data.error || data.message || text || 'Claim failed';
throw new Error(typeof err === 'string' ? err : JSON.stringify(err));
}
show(msg, true, 'Linked to store. Next: add cameras.');
setTimeout(() => setStep(1), 500);
} catch (e) { show(msg, false, e.message || String(e)); }
Expand All @@ -214,8 +225,13 @@ def wizard_page_html(route_prefix: str = "") -> str:
fd.append('loop_file', document.getElementById('loopFile').checked ? 'true' : 'false');
try {
const r = await fetch('wizard/sources', { method: 'POST', body: fd });
const data = await r.json();
if (!r.ok) throw new Error(data.detail || data.error || 'Save failed');
const text = await r.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch { data = { detail: text || r.statusText }; }
if (!r.ok) {
const err = data.detail || data.error || text || 'Save failed';
throw new Error(typeof err === 'string' ? err : JSON.stringify(err));
}
document.getElementById('doneText').textContent =
`Monitoring ${data.cameraCount} source(s). Clips upload on motion.`;
setStep(2);
Expand Down Expand Up @@ -268,18 +284,18 @@ def wizard_status():
}

@app.post(f"{api_prefix}/claim")
def wizard_claim(body: dict):
code = (body.get("setupCode") or body.get("setup_code") or "").strip()
name = (body.get("name") or cfg.connector_name or "edge-connector-1").strip()
def wizard_claim(body: WizardClaimBody):
code = (body.setupCode or "").strip().upper()
name = (body.name or cfg.connector_name or "edge-connector-1").strip()
if not code:
raise HTTPException(400, "setupCode is required")
raise HTTPException(status_code=400, detail="setupCode is required")
try:
w = load_wizard_config() or WizardConfig()
w.setup_code = code
w.connector_name = name
cid, store_id = claim_setup(client, store, w, cfg.version)
except Exception as exc: # noqa: BLE001
raise HTTPException(400, str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
state.connector_id = cid
state.log(f"Wizard: claimed setup code → connector {cid} store {store_id}")
w = load_wizard_config() or WizardConfig()
Expand Down
19 changes: 11 additions & 8 deletions docs/AZURE_MVP_DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Deploy ONEVO to a **single GPU Azure VM** with **Docker Compose**, built and rel
|-----------|----------------|
| Backend, dashboard, cloud-ai, Postgres, Redis, MinIO | Azure GPU VM (Docker Compose) |
| Windows connector | **Shop PCs** — downloaded from dashboard after login |
| CI/CD | GitHub Actions → ACR → SSH deploy to VM |
| CI/CD | GitHub Actions or **Jenkins** → ACR or VM build → SSH deploy to VM |

Shop staff download `ONEVO-Connector-Setup-*.exe` from **Get started / Admin / Setup** in the dashboard. The backend serves the file from `/opt/onevo/connector/dist/` on the VM (mounted into the backend container). The connector service itself does **not** run in Azure.
Shop staff download `ONEVO-Connector-Setup-*.exe` from **Get started / Admin / Setup** in the dashboard. The backend serves the file from `/opt/onevo/app/installer-site/` on the VM (mounted into the backend container). The connector service itself does **not** run in Azure.

## 1. Provision Azure (one time)

Expand All @@ -33,8 +33,8 @@ SSH to the VM and bootstrap:

```bash
ssh azureuser@<VM_PUBLIC_IP>
git clone <your-repo-url> /opt/onevo
cd /opt/onevo
git clone <your-repo-url> /opt/onevo/app
cd /opt/onevo/app
sudo ACR_LOGIN_SERVER=<acr>.azurecr.io bash infra/mvp/vm-setup.sh
```

Expand All @@ -47,8 +47,8 @@ sudo certbot --nginx -d app.yourdomain.example -d api.yourdomain.example
Copy and fill secrets:

```bash
cp infra/mvp/.env.production.example /opt/onevo/.env
nano /opt/onevo/.env
cp infra/mvp/.env.production.example /opt/onevo/app/.env
nano /opt/onevo/app/.env
```

## 2. GitHub secrets
Expand Down Expand Up @@ -128,7 +128,7 @@ Manual deploy: Actions → **Deploy MVP (Azure)** → Run workflow.
|-------|-----|
| GPU quota denied | Use CPU VM or request quota increase; set `CLOUD_AI_DEVICE=cpu` in `.env` |
| ACR pull 401 on VM | Check `ACR_*` in `.env`; run `docker login` manually on VM |
| Installer 404 | Ensure `build-installer` job succeeded and EXE exists in `/opt/onevo/connector/dist/` |
| Installer 404 | Ensure `build-installer` job succeeded and EXE exists in `/opt/onevo/app/installer-site/` |
| CORS errors | Set `CORS_ORIGINS=https://app.yourdomain.example` in `.env` |
| Deploy SSH fails | Verify `VM_SSH_KEY`, NSG allows SSH from GitHub Actions IPs (or use self-hosted runner in same VNet) |
| Clip upload timeout (`:9000`) | NSG must allow **9000**; set `S3_PUBLIC_ENDPOINT=http://<VM_IP>:9000` in `.env`; test `curl http://<VM_IP>:9000/minio/health/live` from shop PC |
Expand All @@ -140,7 +140,10 @@ Manual deploy: Actions → **Deploy MVP (Azure)** → Run workflow.
|------|---------|
| [`infra/mvp/provision-azure.sh`](../infra/mvp/provision-azure.sh) | Create RG, ACR, VM |
| [`infra/mvp/vm-setup.sh`](../infra/mvp/vm-setup.sh) | Docker, NVIDIA toolkit, nginx on VM |
| [`infra/mvp/deploy.sh`](../infra/mvp/deploy.sh) | Pull ACR images and restart compose |
| [`infra/mvp/deploy.sh`](../infra/mvp/deploy.sh) | Pull ACR images or local build; restart compose |
| [`scripts/deploy-vm.ps1`](../scripts/deploy-vm.ps1) | Jenkins / manual deploy from Windows |
| [`Jenkinsfile`](../Jenkinsfile) | Jenkins pipeline definition |
| [`docs/JENKINS_DEPLOY.md`](JENKINS_DEPLOY.md) | Jenkins setup on Windows |
| [`infra/mvp/nginx-host.conf`](../infra/mvp/nginx-host.conf) | Host TLS reverse proxy template |
| [`infra/mvp/.env.production.example`](../infra/mvp/.env.production.example) | Production env template |
| [`docker-compose.acr.yml`](../docker-compose.acr.yml) | Use pre-built images from ACR |
Expand Down
Loading
Loading