feat: implement update backend API and client logic

Add complete update mechanism for client-side updates:
- backend/update.py: Core update logic (check, apply, rollback, status/logs)
- backend/app.py: REST API endpoints (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs)
- backend/models.py: Pydantic models for update API responses
- backend/settings.py: Update config (status/log file paths)
- scripts/rollback_client.sh: Rollback script for failed updates
- scripts/update_client.sh: Enhanced update client script
- CLAUDE.md: Documentation for future Claude Code instances

Complete US_000026-028 and TASK_000027-029:
- US_000026: Client pulls updates from remote service
- US_000027: Client verifies and applies updates atomically
- US_000028: Client reports update status to backend

All endpoints require authentication. Updates run asynchronously.
Documentation updated per SOP (CHANGELOG, PROJECT_STATUS, stories/tasks).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 11:18:34 +01:00
parent b64cc5981c
commit 107cdabe8d
15 changed files with 530 additions and 19 deletions

View File

@ -34,6 +34,10 @@ By: Codex (GPT-5)
| 28.12.2025 | 📝 Req | ID: Update-API Endpunkte dokumentiert. By: Codex (GPT-5) |
| 28.12.2025 | 📝 Req | ID: Update-Status/Log Dateien dokumentiert. By: Codex (GPT-5) |
| 28.12.2025 | ⚙️ Code | ID: Update-Client Prototyp-Skript hinzugefuegt. By: Codex (GPT-5) |
| 29.12.2025 | ⚙️ Code | ID: Update-Backend Logik implementiert (backend/update.py). By: Codex (GPT-5) |
| 29.12.2025 | ⚙️ Code | ID: Update-Models und Settings erweitert (Models, Status-File Paths). By: Codex (GPT-5) |
| 29.12.2025 | ⚙️ Code | ID: Rollback-Script hinzugefuegt (scripts/rollback_client.sh). By: Codex (GPT-5) |
| 30.12.2025 | ⚙️ Code | ID: Update-API Endpunkte implementiert (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs). By: Claude Sonnet 4.5 |
---
## Legende

145
CLAUDE.md Normal file
View File

@ -0,0 +1,145 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Safe Kiddo Daemon (SKD) is a FastAPI-based service for managing local user accounts on kids' laptops. It provides account locking/unlocking with countdown notifications, optional sound alerts, and shutdown capabilities. The service exposes a REST API with bearer token auth (PAM or OIDC) and serves a minimal web UI.
## Commands
### Development
```bash
# Run the service manually (uses .venv, binds to 0.0.0.0:80)
./scripts/run.sh
# Install service and dependencies
sudo make install
# Service management
sudo make up # Start service
sudo make down # Stop service
sudo make update # Pull latest from git, reinstall deps, restart
# Generate/set API token
make token
# Health check (requires token)
make healthcheck
```
### Installation & Deployment
```bash
# Full install (creates service user, venv, systemd unit, PAM config)
sudo ./scripts/install.sh
# Deploy to remote host (requires deploy_hosts.yml)
./scripts/deploy.sh <host-name>
# Manual update on target
ssh user@target 'cd /opt/sk && ./scripts/update.sh'
```
### Testing
```bash
# Python syntax validation
python -m py_compile backend/*.py
# Run specific tests (no formal test runner yet; tests/ is empty)
# Use curl for API testing:
token=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"root","password":"..."}' \
http://localhost/login | jq -r .token)
curl -H "Authorization: Bearer $token" http://localhost/users
```
## Architecture
### Core Structure
- `backend/`: FastAPI application
- `app.py`: Main FastAPI app with route handlers
- `actions.py`: User management actions (lock/unlock, notifications, shutdown logic)
- `auth.py`: PAM authentication, JWT tokens, user/group authorization checks
- `oidc.py`: OIDC client (dynamic discovery, token exchange, claims validation)
- `update.py`: Update client logic (check/status/logs, triggers async update/rollback scripts)
- `settings.py`: Environment-based configuration (Settings class, singleton via lru_cache)
- `models.py`: Pydantic models for API requests/responses
- `templates/`: Jinja2 templates for web UI
- `scripts/`: Deployment and lifecycle scripts
- `install.sh`: System setup (user, venv, systemd, PAM config)
- `run.sh`: Manual service start
- `update.sh`: Local git pull and service restart
- `update_client.sh`: Full update flow with backup/rollback
- `rollback_client.sh`: Restore from backup if update fails
- `deploy.sh`: SSH-based deployment to remote hosts
- `register_oidc_client.sh`: OIDC dynamic client registration helper
- `sk.sh`: Legacy bash script (CLI fallback for direct SSH use)
- `src/`: Hexagonal architecture skeleton (core/ports/adapters/ui) - currently empty placeholders
- `docs/`: Detailed specs for OIDC validation, update API, status/log formats
- `Makefile`: Convenience targets for install, service control, updates, token management
### Key Architectural Patterns
**Dual Authentication**: PAM-based local auth (root/sudo users) is always available; OIDC is optional if `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, and `SKD_OIDC_CLIENT_SECRET` are configured. Both modes issue JWT bearer tokens.
**Settings Management**: All config via environment variables (loaded from `/etc/skd/env` in production). `settings.py` provides a singleton `Settings` instance via `get_settings()` using `lru_cache`. FastAPI dependencies inject settings into route handlers.
**Action Execution**: `actions.py` wraps all privileged operations (usermod, pkill, shutdown) via `_run()` helper. Dry-run mode (`SKD_DRY_RUN=true`) logs commands without executing them.
**Update Flow**: `update.py` checks remote update service for new versions, writes status to JSON files, and triggers async scripts (`update_client.sh`, `rollback_client.sh`) that create backups, apply updates, and handle rollbacks on failure.
**Authorization**: `auth.py` checks both user allowlists (`SKD_AUTH_ALLOWED_USERS`) and group membership (`SKD_AUTH_ALLOWED_GROUPS`, defaults to `sudo`). UID 0 (root) always allowed for PAM. OIDC validates against `preferred_username`, `email`, or `sub` claims.
**Manageable Users**: Only system users with UID >= 1000, real shells (not nologin/false), and optional allowlist (`SKD_ALLOWED_USERS`) are exposed via API. Root accounts are never manageable.
## Configuration
Deployment config lives in `/etc/skd/env` (see `env.example` in repo root):
- `SKD_AUTH_SECRET`: HMAC secret for JWT signing (must be strong in production)
- `SKD_AUTH_ALLOWED_USERS`: Comma-separated user allowlist (for login and OIDC claims)
- `SKD_AUTH_ALLOWED_GROUPS`: Groups whose members may log in (PAM only, default `sudo`)
- `SKD_AUTH_PAM_SERVICE`: PAM service name (Ubuntu/Debian use `skd`, others may use `login`)
- `SKD_OIDC_*`: OIDC provider config (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SCOPES)
- `SKD_ALLOWED_USERS`: Comma-separated list of manageable system accounts (optional)
- `SKD_DRY_RUN`: If `true`, logs all privileged commands without executing
- `SKD_UPDATE_*`: Update service URL, token, interval, status/log file paths
## Important Workflows
### Disable User Flow
1. API call to `/users/{username}/disable` with optional `{countdown, sound, message}`
2. `actions.disable_user()` locks account via `usermod -L`
3. If user logged in: sends desktop notifications, plays sound (if enabled), countdown loop with periodic reminders
4. Terminates sessions via `pkill -KILL -u`
5. Triggers `shutdown now` only if user was logged in
### OIDC Login Flow
1. User accesses `/login/oidc/start` → redirected to provider with state cookie
2. Provider redirects to `/login/oidc/callback` with code + state
3. Validates state, exchanges code for tokens, extracts username from claims
4. Issues JWT session cookie if user in allowlist
### Update Flow
1. `check_update()` polls remote update service for latest manifest (version, artifact_url, sha256)
2. `start_update()` writes "in_progress" status, launches `update_client.sh` in background
3. Script creates backup, downloads artifact, verifies checksum, installs, restarts service
4. On failure: `rollback_client.sh` restores from backup
5. Status/logs written to JSON files at `SKD_UPDATE_STATUS_FILE` and `SKD_UPDATE_LOG_FILE`
## Security Considerations
- Service runs as root by default (required for PAM, usermod, pkill, shutdown). Limit exposure via firewall.
- Set strong `SKD_AUTH_SECRET` and rotate by changing value + restarting service.
- Restrict API/Web UI to LAN/VPN; consider mTLS or IP allowlisting.
- `skd` user/group created by install script; consider sudoers rules to limit privileges to specific commands.
- OIDC redirect URI must match exactly (no wildcards); re-register client if host/port changes.
- Validate TLS certificates in production; self-signed certs require CA trust or fallback to PAM.
## Notes
- Legacy `sk.sh` remains for emergency CLI fallback; API is preferred for all operations.
- `src/` hexagonal architecture skeleton is currently unused; logic lives in `backend/`.
- `tests/` directory exists but is empty; use manual curl-based API testing.
- Both German and English comments exist in code; favor English going forward.
- Deployment via `deploy.sh` supports both YAML (`deploy_hosts.yml`) and JSON host configs.
- PAM service file (`/etc/pam.d/skd`) created by `scripts/install.sh` on Ubuntu/Debian; other distros may need manual setup.

View File

@ -15,9 +15,20 @@ from backend.auth import (
issue_token,
list_manageable_users,
)
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
from backend.models import (
ActionRequest,
ActionResponse,
LoginRequest,
LoginResponse,
UpdateActionResponse,
UpdateCheckResponse,
UpdateLogEntry,
UpdateStatus,
UserStatus,
)
from backend.oidc import OIDCClient, OIDCError
from backend.settings import Settings, get_settings
from backend import update
logging.basicConfig(
level=logging.INFO,
@ -219,6 +230,67 @@ def enable_user(
)
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
status_data = update.get_status(settings)
return UpdateStatus(**status_data)
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
try:
check_data = update.check_update(settings)
except Exception as exc:
logger.exception("Update check failed")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Update check failed: {str(exc)}",
) from exc
return UpdateCheckResponse(**check_data)
@app.post("/update/apply", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
def update_apply(
settings: Settings = Depends(get_settings),
payload: dict | None = Body(default=None),
) -> UpdateActionResponse:
version = payload.get("version") if payload else None
try:
update.start_update(settings, version)
except Exception as exc:
logger.exception("Failed to start update")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start update: {str(exc)}",
) from exc
return UpdateActionResponse(started=True, message="Update started")
@app.post("/update/rollback", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
def update_rollback(settings: Settings = Depends(get_settings)) -> UpdateActionResponse:
try:
update.start_rollback(settings)
except Exception as exc:
logger.exception("Failed to start rollback")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start rollback: {str(exc)}",
) from exc
return UpdateActionResponse(started=True, message="Rollback started")
@app.get("/update/logs", dependencies=[Depends(get_current_admin)])
def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) -> list[dict]:
try:
return update.get_logs(settings, limit)
except Exception as exc:
logger.exception("Failed to retrieve update logs")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve logs: {str(exc)}",
) from exc
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request})

View File

@ -34,3 +34,32 @@ class LoginRequest(BaseModel):
class LoginResponse(BaseModel):
token: str
expires_in: int
class UpdateStatus(BaseModel):
current_version: str
last_status: str
last_error: Optional[str] = None
last_timestamp: Optional[str] = None
class UpdateCheckResponse(BaseModel):
available: bool
latest_version: str
artifact_url: str
sha256: str
message: Optional[str] = None
class UpdateActionResponse(BaseModel):
started: bool
message: str
class UpdateLogEntry(BaseModel):
timestamp: str
status: str
message: Optional[str] = None
version: Optional[str] = None
error: Optional[str] = None
device_id: Optional[str] = None

View File

@ -45,6 +45,12 @@ class Settings:
self.update_status_url: str = os.getenv(
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
)
self.update_status_file: str = os.getenv(
"SKD_UPDATE_STATUS_FILE", "/var/lib/skd/update_status.json"
)
self.update_log_file: str = os.getenv(
"SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl"
)
# Paths/tools
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")

142
backend/update.py Normal file
View File

@ -0,0 +1,142 @@
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
import httpx
from backend.settings import Settings
def _project_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_version() -> str:
try:
return (_project_root() / "VERSION").read_text(encoding="utf-8").strip()
except OSError:
return "unknown"
def _status_path(settings: Settings) -> Path:
return Path(settings.update_status_file)
def _log_path(settings: Settings) -> Path:
return Path(settings.update_log_file)
def _ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def _write_status(settings: Settings, status: str, version: str, error: str | None = None) -> None:
status_path = _status_path(settings)
_ensure_parent(status_path)
payload = {
"device_id": os.uname().nodename,
"version": version,
"status": status,
"error": error or "",
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
status_path.write_text(json.dumps(payload), encoding="utf-8")
_append_log(settings, payload)
def _append_log(settings: Settings, payload: Dict[str, Any]) -> None:
log_path = _log_path(settings)
_ensure_parent(log_path)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
def get_status(settings: Settings) -> Dict[str, Any]:
current_version = _read_version()
status_path = _status_path(settings)
if status_path.exists():
try:
data = json.loads(status_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
else:
data = {}
return {
"current_version": current_version,
"last_status": data.get("status", "unknown"),
"last_error": data.get("error") or None,
"last_timestamp": data.get("timestamp"),
}
def _parse_version(value: str) -> List[int]:
return [int(part) for part in value.split(".")]
def check_update(settings: Settings) -> Dict[str, Any]:
headers = {}
if settings.update_token:
headers["Authorization"] = f"Bearer {settings.update_token}"
with httpx.Client(timeout=10.0) as client:
response = client.get(settings.update_url, headers=headers)
response.raise_for_status()
manifest = response.json()
latest_version = manifest.get("version", "")
artifact_url = manifest.get("artifact_url", "")
sha256 = manifest.get("sha256", "")
message = manifest.get("message")
available = False
current_version = _read_version()
try:
available = _parse_version(latest_version) > _parse_version(current_version)
except ValueError:
if latest_version and latest_version != current_version:
available = True
return {
"available": available,
"latest_version": latest_version,
"artifact_url": artifact_url,
"sha256": sha256,
"message": message,
}
def _run_async(script_path: Path, settings: Settings) -> None:
env = os.environ.copy()
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
subprocess.Popen([str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def start_update(settings: Settings, version: str | None = None) -> None:
_write_status(settings, "in_progress", version or _read_version())
script = _project_root() / "scripts" / "update_client.sh"
_run_async(script, settings)
def start_rollback(settings: Settings) -> None:
_write_status(settings, "in_progress", _read_version())
script = _project_root() / "scripts" / "rollback_client.sh"
_run_async(script, settings)
def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]:
log_path = _log_path(settings)
if not log_path.exists():
return []
lines = log_path.read_text(encoding="utf-8").splitlines()
entries: List[Dict[str, Any]] = []
for line in lines[-limit:]:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries

View File

@ -86,12 +86,12 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [x] TASK_000023: README runbook notes
### EPIC_000008: Client-Side Update Mechanism
- [ ] US_000026: Client bezieht Updates (Pull)
- [ ] TASK_000027: Update endpoint config
- [ ] US_000027: Client verifiziert und wendet Updates an
- [ ] TASK_000028: Verify and apply update
- [ ] US_000028: Client meldet Update-Status
- [ ] TASK_000029: Report update status
- [x] US_000026: Client bezieht Updates (Pull)
- [x] TASK_000027: Update endpoint config
- [x] US_000027: Client verifiziert und wendet Updates an
- [x] TASK_000028: Verify and apply update
- [x] US_000028: Client meldet Update-Status
- [x] TASK_000029: Report update status
- [ ] US_000029: Update-Status im Web-UI anzeigen
- [ ] TASK_000030: UI update status view
- [ ] US_000030: Update-Check im Web-UI ausloesen

View File

@ -1,9 +1,9 @@
ID: US_000026 | Version: 0.1.0 | Status: Draft
ID: US_000026 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# US_000026: Client bezieht Updates (Pull)
Status: In Progress
Status: Done
Als Betreiber moechte ich, dass der Client Updates per Pull von einem Update-Service bezieht, damit Deployments ohne SSH moeglich sind.

View File

@ -1,9 +1,9 @@
ID: US_000027 | Version: 0.1.0 | Status: Draft
ID: US_000027 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# US_000027: Client verifiziert und wendet Updates an
Status: In Progress
Status: Done
Als Betreiber moechte ich, dass der Client Updates verifiziert und sicher anwendet, damit fehlerhafte Pakete keine Ausfaelle verursachen.

View File

@ -1,9 +1,9 @@
ID: US_000028 | Version: 0.1.0 | Status: Draft
ID: US_000028 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# US_000028: Client meldet Update-Status
Status: In Progress
Status: Done
Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind.

View File

@ -1,4 +1,4 @@
ID: TASK_000027 | Version: 0.1.0 | Status: In Progress
ID: TASK_000027 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# TASK_000027: Update endpoint config

View File

@ -1,4 +1,4 @@
ID: TASK_000028 | Version: 0.1.0 | Status: In Progress
ID: TASK_000028 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# TASK_000028: Verify and apply update

View File

@ -1,4 +1,4 @@
ID: TASK_000029 | Version: 0.1.0 | Status: In Progress
ID: TASK_000029 | Version: 0.1.0 | Status: Done
By: Codex (GPT-5)
# TASK_000029: Report update status

76
scripts/rollback_client.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
SERVICE_NAME="${SERVICE_NAME:-skd}"
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}"
LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
write_status() {
local status="$1"
local error="${2:-}"
local version="$3"
SKD_STATUS="${status}" SKD_ERROR="${error}" SKD_VERSION="${version}" \
SKD_STATUS_FILE="${STATUS_FILE}" SKD_LOG_FILE="${LOG_FILE}" python3 - <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
status = os.environ["SKD_STATUS"]
error = os.environ.get("SKD_ERROR", "")
version = os.environ.get("SKD_VERSION", "unknown")
status_file = Path(os.environ["SKD_STATUS_FILE"])
log_file = Path(os.environ["SKD_LOG_FILE"])
status_file.parent.mkdir(parents=True, exist_ok=True)
log_file.parent.mkdir(parents=True, exist_ok=True)
payload = {
"device_id": os.uname().nodename,
"version": version,
"status": status,
"error": error,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
status_file.write_text(json.dumps(payload), encoding="utf-8")
with log_file.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
PY
}
LATEST_BACKUP="$(ls -dt /opt/sk_backup_* 2>/dev/null | head -1 || true)"
if [[ -z "${LATEST_BACKUP}" ]]; then
log "No backup found; rollback aborted."
write_status "failed" "no backup found" "unknown"
exit 1
fi
VERSION="unknown"
if [[ -f "${LATEST_BACKUP}/VERSION" ]]; then
VERSION="$(cat "${LATEST_BACKUP}/VERSION" | tr -d '\n')"
fi
log "Stopping service ${SERVICE_NAME}..."
sudo systemctl stop "${SERVICE_NAME}.service"
FAILED_DIR="${INSTALL_DIR}_failed_$(date +%s)"
log "Swapping ${INSTALL_DIR} -> ${FAILED_DIR}..."
if [[ -d "${INSTALL_DIR}" ]]; then
sudo mv "${INSTALL_DIR}" "${FAILED_DIR}"
fi
sudo mv "${LATEST_BACKUP}" "${INSTALL_DIR}"
log "Starting service ${SERVICE_NAME}..."
if sudo systemctl start "${SERVICE_NAME}.service"; then
log "Rollback completed."
write_status "success" "" "${VERSION}"
else
log "Rollback failed."
write_status "failed" "service start failed" "${VERSION}"
exit 1
fi

View File

@ -5,6 +5,8 @@ SERVICE_NAME="${SERVICE_NAME:-skd}"
UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}"
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
STATUS_URL="${SKD_UPDATE_STATUS_URL:-https://update.wlkns.org/status}"
STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}"
LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}"
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
STAGING_DIR=""
MANIFEST_FILE=""
@ -20,6 +22,35 @@ report_status() {
local error="${2:-}"
local timestamp
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
python3 - <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
device_id = os.uname().nodename
version = os.environ.get("SKD_VERSION", "unknown")
status = os.environ.get("SKD_STATUS", "unknown")
error = os.environ.get("SKD_ERROR", "")
timestamp = os.environ.get("SKD_TIMESTAMP") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
status_file = Path(os.environ["SKD_STATUS_FILE"])
log_file = Path(os.environ["SKD_LOG_FILE"])
status_file.parent.mkdir(parents=True, exist_ok=True)
log_file.parent.mkdir(parents=True, exist_ok=True)
payload = {
"device_id": device_id,
"version": version,
"status": status,
"error": error,
"timestamp": timestamp,
}
status_file.write_text(json.dumps(payload), encoding="utf-8")
with log_file.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
PY
local payload
payload=$(cat <<JSON
{"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"}
@ -99,7 +130,9 @@ log "Verifying checksum..."
CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')"
if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then
echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&2
report_status "failed" "checksum mismatch"
SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="checksum mismatch" \
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "checksum mismatch"
exit 1
fi
@ -121,7 +154,9 @@ STAGING_DIR=""
log "Starting service ${SERVICE_NAME}..."
if sudo systemctl start "${SERVICE_NAME}.service"; then
log "Update applied successfully."
report_status "success" ""
SKD_VERSION="${VERSION}" SKD_STATUS="success" SKD_ERROR="" \
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
SKD_LOG_FILE="${LOG_FILE}" report_status "success" ""
else
log "Service failed to start, rolling back..."
sudo rm -rf "${INSTALL_DIR}"
@ -129,6 +164,8 @@ else
sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}"
fi
sudo systemctl start "${SERVICE_NAME}.service" || true
report_status "failed" "service start failed"
SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="service start failed" \
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "service start failed"
exit 1
fi