feat(infra): add webhook-based schema deployment pipeline
- Add FastAPI webhook receiver for Forgejo push events - Add setup script for server deployment - Add Caddy snippet for webhook endpoint - Add local sync-schemas.sh helper script - Sync frontend schemas with source (archived deprecated slots) Infrastructure scripts staged for optional webhook deployment. Current deployment uses: ./infrastructure/deploy.sh --frontend
This commit is contained in:
parent
f02cffe1e8
commit
a4184cb805
18 changed files with 2368 additions and 2076 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ imports:
|
||||||
- ../metadata
|
- ../metadata
|
||||||
- ./SpecificityAnnotation
|
- ./SpecificityAnnotation
|
||||||
- ./TemplateSpecificityScores
|
- ./TemplateSpecificityScores
|
||||||
- ../slots/access
|
- ../slots/has_or_had_access_condition
|
||||||
- ../slots/full_name
|
- ../slots/full_name
|
||||||
- ../slots/isil
|
- ../slots/isil
|
||||||
- ../slots/location
|
- ../slots/location
|
||||||
|
|
@ -34,7 +34,7 @@ classes:
|
||||||
Used for key_archives (main archives for a topic) and related_archives
|
Used for key_archives (main archives for a topic) and related_archives
|
||||||
(external archives with related holdings).
|
(external archives with related holdings).
|
||||||
slots:
|
slots:
|
||||||
- access
|
- has_or_had_access_condition
|
||||||
- full_name
|
- full_name
|
||||||
- isil
|
- isil
|
||||||
- location
|
- location
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ imports:
|
||||||
- ./FindingAid
|
- ./FindingAid
|
||||||
- ./ExhibitedObject
|
- ./ExhibitedObject
|
||||||
- ./CurationActivity
|
- ./CurationActivity
|
||||||
- ../slots/access_policy_ref
|
- ../slots/has_or_had_access_policy_reference
|
||||||
- ../slots/has_acquisition_date
|
- ../slots/has_acquisition_date
|
||||||
- ../slots/has_acquisition_method
|
- ../slots/has_acquisition_method
|
||||||
- ../slots/has_acquisition_source
|
- ../slots/has_acquisition_source
|
||||||
|
|
@ -166,7 +166,7 @@ classes:
|
||||||
- rico:Record
|
- rico:Record
|
||||||
- bf:Item
|
- bf:Item
|
||||||
slots:
|
slots:
|
||||||
- access_policy_ref
|
- has_or_had_access_policy_reference
|
||||||
- has_acquisition_date
|
- has_acquisition_date
|
||||||
- has_acquisition_method
|
- has_acquisition_method
|
||||||
- has_acquisition_source
|
- has_acquisition_source
|
||||||
|
|
@ -408,7 +408,7 @@ classes:
|
||||||
description: Source of VOC archives transfer
|
description: Source of VOC archives transfer
|
||||||
- value: Estate of Anna Drucker-Fraser
|
- value: Estate of Anna Drucker-Fraser
|
||||||
description: Source of bequest
|
description: Source of bequest
|
||||||
has_access_policy_reference:
|
has_or_had_access_policy_reference:
|
||||||
slot_uri: premis:hasRightsDeclaration
|
slot_uri: premis:hasRightsDeclaration
|
||||||
description: |
|
description: |
|
||||||
Access policy governing this collection.
|
Access policy governing this collection.
|
||||||
|
|
@ -679,7 +679,7 @@ classes:
|
||||||
acquisition_method: TRANSFER
|
acquisition_method: TRANSFER
|
||||||
acquisition_date: '1856-01-01'
|
acquisition_date: '1856-01-01'
|
||||||
acquisition_source: Ministry of Colonies
|
acquisition_source: Ministry of Colonies
|
||||||
access_policy_ref: https://nde.nl/ontology/hc/access-policy/open-access
|
has_or_had_access_policy_reference: https://nde.nl/ontology/hc/access-policy/open-access
|
||||||
arrangement: Organized by provenance, then chronologically
|
arrangement: Organized by provenance, then chronologically
|
||||||
has_or_had_finding_aid:
|
has_or_had_finding_aid:
|
||||||
- finding_aid_id: https://nde.nl/finding-aid/nationaal-archief-voc-inventory
|
- finding_aid_id: https://nde.nl/finding-aid/nationaal-archief-voc-inventory
|
||||||
|
|
@ -699,5 +699,5 @@ classes:
|
||||||
# NOTE: All slots are defined in centralized modules/slots/ files
|
# NOTE: All slots are defined in centralized modules/slots/ files
|
||||||
# Slots used by this class: collection_id, collection_type_ref, record_set_type,
|
# Slots used by this class: collection_id, collection_type_ref, record_set_type,
|
||||||
# extent_items, subject_areas, provenance_statement, custodial_history, acquisition_source,
|
# extent_items, subject_areas, provenance_statement, custodial_history, acquisition_source,
|
||||||
# access_policy_ref, arrangement, finding_aids, digital_surrogate_url, parent_collection,
|
# has_or_had_access_policy_reference, arrangement, finding_aids, digital_surrogate_url, parent_collection,
|
||||||
# has_or_had_sub_collection, items, curation_activities, part_of_custodian_collection
|
# has_or_had_sub_collection, items, curation_activities, part_of_custodian_collection
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ imports:
|
||||||
- ./ReconstructedEntity
|
- ./ReconstructedEntity
|
||||||
- ./CustodianObservation
|
- ./CustodianObservation
|
||||||
- ./ReconstructionActivity
|
- ./ReconstructionActivity
|
||||||
- ../slots/accepts_external_work
|
- ../slots/accepts_or_accepted_external_work
|
||||||
- ../slots/has_or_had_accreditation_body
|
- ../slots/has_or_had_accreditation_body
|
||||||
- ../slots/conservation_specialization
|
- ../slots/conservation_specialization
|
||||||
- ../slots/equipment_type
|
- ../slots/equipment_type
|
||||||
|
|
@ -126,7 +126,7 @@ classes:
|
||||||
- crm:E14_Condition_Assessment
|
- crm:E14_Condition_Assessment
|
||||||
- schema:ResearchOrganization
|
- schema:ResearchOrganization
|
||||||
slots:
|
slots:
|
||||||
- accepts_external_work
|
- accepts_or_accepted_external_work
|
||||||
- has_or_had_accreditation_body
|
- has_or_had_accreditation_body
|
||||||
- conservation_specialization
|
- conservation_specialization
|
||||||
- equipment_type
|
- equipment_type
|
||||||
|
|
@ -416,7 +416,7 @@ classes:
|
||||||
is_accredited: true
|
is_accredited: true
|
||||||
accreditation_body: VeRes
|
accreditation_body: VeRes
|
||||||
staff_count: 12
|
staff_count: 12
|
||||||
accepts_external_work: false
|
accepts_or_accepted_external_work: false
|
||||||
description: Major museum conservation studio
|
description: Major museum conservation studio
|
||||||
- value:
|
- value:
|
||||||
lab_id: https://nde.nl/ontology/hc/aux/na-restauratie
|
lab_id: https://nde.nl/ontology/hc/aux/na-restauratie
|
||||||
|
|
@ -437,7 +437,7 @@ classes:
|
||||||
has_fume_hoods: true
|
has_fume_hoods: true
|
||||||
has_deacidification_facility: true
|
has_deacidification_facility: true
|
||||||
staff_count: 6
|
staff_count: 6
|
||||||
accepts_external_work: true
|
accepts_or_accepted_external_work: true
|
||||||
description: Archive paper conservation workshop
|
description: Archive paper conservation workshop
|
||||||
slots:
|
slots:
|
||||||
lab_id:
|
lab_id:
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ imports:
|
||||||
- ../slots/publisher
|
- ../slots/publisher
|
||||||
- ../slots/publication_date
|
- ../slots/publication_date
|
||||||
- ../slots/isbn
|
- ../slots/isbn
|
||||||
- ../slots/access
|
- ../slots/has_or_had_access_condition
|
||||||
- ../slots/is_or_was_access_restricted
|
- ../slots/is_or_was_access_restricted
|
||||||
- ../slots/all_links
|
- ../slots/all_links
|
||||||
- ../slots/card_description
|
- ../slots/card_description
|
||||||
|
|
@ -676,7 +676,7 @@ classes:
|
||||||
Used for key_archives (main archives for a topic) and related_archives
|
Used for key_archives (main archives for a topic) and related_archives
|
||||||
(external archives with related holdings).
|
(external archives with related holdings).
|
||||||
slots:
|
slots:
|
||||||
- access
|
- has_or_had_access_condition
|
||||||
- full_name
|
- full_name
|
||||||
- isil
|
- isil
|
||||||
- location
|
- location
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ imports:
|
||||||
- ../slots/managed_by
|
- ../slots/managed_by
|
||||||
- ../slots/price_currency
|
- ../slots/price_currency
|
||||||
- ./ReconstructedEntity
|
- ./ReconstructedEntity
|
||||||
- ../slots/accepts_payment_methods
|
- ../slots/accepts_or_accepted_payment_method
|
||||||
- ../slots/has_or_had_annual_revenue
|
- ../slots/has_or_had_annual_revenue
|
||||||
- ../slots/giftshop_price_range
|
- ../slots/giftshop_price_range
|
||||||
- ../slots/online_shop
|
- ../slots/online_shop
|
||||||
|
|
@ -65,7 +65,7 @@ classes:
|
||||||
\ materials, replicas for learning\n\n**PHYSICAL vs. DIGITAL PRESENCE**:\n\n\
|
\ materials, replicas for learning\n\n**PHYSICAL vs. DIGITAL PRESENCE**:\n\n\
|
||||||
Gift shops can exist in multiple forms:\n\n1. **Physical shop** (on-site): Located\
|
Gift shops can exist in multiple forms:\n\n1. **Physical shop** (on-site): Located\
|
||||||
\ within museum/archive building\n - Links to AuxiliaryPlace (physical location)\n\
|
\ within museum/archive building\n - Links to AuxiliaryPlace (physical location)\n\
|
||||||
\ - Has opening_hours, accepts_payment_methods\n \n2. **Physical shop**\
|
\ - Has opening_hours, accepts_or_accepted_payment_method\n \n2. **Physical shop**\
|
||||||
\ (separate): Stand-alone retail location\n - Links to AuxiliaryPlace with\
|
\ (separate): Stand-alone retail location\n - Links to AuxiliaryPlace with\
|
||||||
\ type RETAIL_SPACE\n - May have separate street address, hours\n \n3. **Online\
|
\ type RETAIL_SPACE\n - May have separate street address, hours\n \n3. **Online\
|
||||||
\ shop** (e-commerce): Web-based retail platform\n - Links to AuxiliaryDigitalPlatform\
|
\ shop** (e-commerce): Web-based retail platform\n - Links to AuxiliaryDigitalPlatform\
|
||||||
|
|
@ -121,7 +121,7 @@ classes:
|
||||||
- gr:Offering
|
- gr:Offering
|
||||||
- schema:Product
|
- schema:Product
|
||||||
slots:
|
slots:
|
||||||
- accepts_payment_methods
|
- accepts_or_accepted_payment_method
|
||||||
- has_or_had_annual_revenue
|
- has_or_had_annual_revenue
|
||||||
- giftshop_price_range
|
- giftshop_price_range
|
||||||
- managed_by
|
- managed_by
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ imports:
|
||||||
- ./CustodianObservation
|
- ./CustodianObservation
|
||||||
- ./ReconstructionActivity
|
- ./ReconstructionActivity
|
||||||
- ../enums/ResearchCenterTypeEnum
|
- ../enums/ResearchCenterTypeEnum
|
||||||
- ../slots/accepts_visiting_scholars
|
- ../slots/accepts_or_accepted_visiting_scholar
|
||||||
- ../slots/has_or_had_affiliated_university
|
- ../slots/has_or_had_affiliated_university
|
||||||
- ../slots/has_or_had_custodian_type
|
- ../slots/has_or_had_custodian_type
|
||||||
- ../slots/fellows_count
|
- ../slots/fellows_count
|
||||||
|
|
@ -130,7 +130,7 @@ classes:
|
||||||
- hc:ConservationLab
|
- hc:ConservationLab
|
||||||
- hc:EducationCenter
|
- hc:EducationCenter
|
||||||
slots:
|
slots:
|
||||||
- accepts_visiting_scholars
|
- accepts_or_accepted_visiting_scholar
|
||||||
- has_or_had_affiliated_university
|
- has_or_had_affiliated_university
|
||||||
- has_or_had_custodian_type
|
- has_or_had_custodian_type
|
||||||
- fellows_count
|
- fellows_count
|
||||||
|
|
@ -369,7 +369,7 @@ classes:
|
||||||
has_publication_series: true
|
has_publication_series: true
|
||||||
publication_series_name: Rijksmuseum Studies in Art
|
publication_series_name: Rijksmuseum Studies in Art
|
||||||
has_research_library: true
|
has_research_library: true
|
||||||
accepts_visiting_scholars: true
|
accepts_or_accepted_visiting_scholar: true
|
||||||
major_research_project:
|
major_research_project:
|
||||||
- Rembrandt Database
|
- Rembrandt Database
|
||||||
- Operation Night Watch
|
- Operation Night Watch
|
||||||
|
|
@ -391,7 +391,7 @@ classes:
|
||||||
- TU Delft
|
- TU Delft
|
||||||
has_fellows_program: true
|
has_fellows_program: true
|
||||||
fellows_count: 4
|
fellows_count: 4
|
||||||
accepts_visiting_scholars: true
|
accepts_or_accepted_visiting_scholar: true
|
||||||
staff_count: 8
|
staff_count: 8
|
||||||
description: Digital humanities research lab
|
description: Digital humanities research lab
|
||||||
slots:
|
slots:
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
id: https://nde.nl/ontology/hc/slot/has_access_policy_reference
|
id: https://nde.nl/ontology/hc/slot/has_or_had_access_policy_reference
|
||||||
name: has_access_policy_reference_slot
|
name: has_or_had_access_policy_reference_slot
|
||||||
title: Has Access Policy Reference Slot
|
title: Has Or Had Access Policy Reference Slot
|
||||||
prefixes:
|
prefixes:
|
||||||
dcterms: http://purl.org/dc/terms/
|
dcterms: http://purl.org/dc/terms/
|
||||||
hc: https://nde.nl/ontology/hc/
|
hc: https://nde.nl/ontology/hc/
|
||||||
linkml: https://w3id.org/linkml/
|
linkml: https://w3id.org/linkml/
|
||||||
schema: http://schema.org/
|
schema: http://schema.org/
|
||||||
|
premis: http://www.loc.gov/premis/rdf/v3/
|
||||||
imports:
|
imports:
|
||||||
- linkml:types
|
- linkml:types
|
||||||
default_prefix: hc
|
default_prefix: hc
|
||||||
slots:
|
slots:
|
||||||
has_access_policy_reference:
|
has_or_had_access_policy_reference:
|
||||||
description: >-
|
description: >-
|
||||||
Reference (URL or citation) to an access policy document.
|
Reference (URL or citation) to an access policy document.
|
||||||
|
Uses temporal naming to indicate that access policies may change over time.
|
||||||
range: uri
|
range: uri
|
||||||
slot_uri: dcterms:references
|
slot_uri: premis:hasRightsDeclaration
|
||||||
exact_mappings:
|
exact_mappings:
|
||||||
- dcterms:references
|
- premis:hasRightsDeclaration
|
||||||
close_mappings:
|
close_mappings:
|
||||||
|
- dcterms:references
|
||||||
- schema:citation
|
- schema:citation
|
||||||
- dcterms:source
|
- dcterms:source
|
||||||
annotations:
|
annotations:
|
||||||
16
infrastructure/caddy/webhook-snippet.txt
Normal file
16
infrastructure/caddy/webhook-snippet.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# GLAM Deploy Webhook - Add to bronhouder.nl site block
|
||||||
|
# Insert after the /health handler
|
||||||
|
#
|
||||||
|
# This handles webhook callbacks from Forgejo for automatic schema deployment
|
||||||
|
#
|
||||||
|
# To add to /etc/caddy/Caddyfile, insert within the bronhouder.nl block:
|
||||||
|
|
||||||
|
# Webhook endpoint for Forgejo push events
|
||||||
|
handle /webhook/deploy* {
|
||||||
|
reverse_proxy 127.0.0.1:8099 {
|
||||||
|
transport http {
|
||||||
|
read_timeout 120s
|
||||||
|
write_timeout 120s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
infrastructure/scripts/deploy-webhook.py
Normal file
262
infrastructure/scripts/deploy-webhook.py
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Webhook receiver for Forgejo push events.
|
||||||
|
Triggers schema sync when push events are received on the main branch.
|
||||||
|
|
||||||
|
Run with: uvicorn deploy-webhook:app --port 8099 --host 127.0.0.1
|
||||||
|
Or as systemd service: deploy-webhook.service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, Header
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="GLAM Deploy Webhook", version="1.0.0")
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||||
|
REPO_PATH = Path("/var/lib/glam/repo")
|
||||||
|
SCRIPTS_PATH = Path("/var/lib/glam/scripts")
|
||||||
|
FRONTEND_PATH = Path("/var/www/glam-frontend")
|
||||||
|
LINKML_SOURCE = REPO_PATH / "schemas/20251121/linkml"
|
||||||
|
LINKML_DEST = FRONTEND_PATH / "schemas/20251121/linkml"
|
||||||
|
|
||||||
|
# Lock to prevent concurrent deployments
|
||||||
|
deploy_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload: bytes, signature: str) -> bool:
|
||||||
|
"""Verify Forgejo webhook signature."""
|
||||||
|
if not WEBHOOK_SECRET:
|
||||||
|
logger.warning("WEBHOOK_SECRET not set, skipping signature verification")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not signature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected = hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode(),
|
||||||
|
payload,
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
# Forgejo uses sha256=<hex> format
|
||||||
|
if signature.startswith("sha256="):
|
||||||
|
signature = signature[7:]
|
||||||
|
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
class DeployResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
details: Optional[dict] = None
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
async def run_command(cmd: list[str], cwd: Optional[Path] = None) -> tuple[int, str, str]:
|
||||||
|
"""Run a shell command asynchronously."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
return process.returncode, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_schemas() -> dict:
|
||||||
|
"""Sync LinkML schemas from repo to frontend public directory."""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
logger.info(f"Pulling latest changes in {REPO_PATH}")
|
||||||
|
code, stdout, stderr = await run_command(["git", "pull", "origin", "main"], cwd=REPO_PATH)
|
||||||
|
results["git_pull"] = {
|
||||||
|
"success": code == 0,
|
||||||
|
"stdout": stdout[:500] if stdout else "",
|
||||||
|
"stderr": stderr[:500] if stderr else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
logger.error(f"Git pull failed: {stderr}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Ensure destination directory exists
|
||||||
|
LINKML_DEST.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Sync LinkML schemas using rsync
|
||||||
|
logger.info(f"Syncing schemas: {LINKML_SOURCE} -> {LINKML_DEST}")
|
||||||
|
code, stdout, stderr = await run_command([
|
||||||
|
"rsync", "-av", "--delete",
|
||||||
|
"--exclude", "*.pyc",
|
||||||
|
"--exclude", "__pycache__",
|
||||||
|
"--exclude", ".git",
|
||||||
|
f"{LINKML_SOURCE}/",
|
||||||
|
f"{LINKML_DEST}/"
|
||||||
|
])
|
||||||
|
results["rsync_linkml"] = {
|
||||||
|
"success": code == 0,
|
||||||
|
"stdout": stdout[:1000] if stdout else "",
|
||||||
|
"stderr": stderr[:500] if stderr else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
logger.error(f"rsync failed: {stderr}")
|
||||||
|
else:
|
||||||
|
logger.info("Schema sync completed successfully")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "ok", "service": "deploy-webhook"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/webhook/deploy")
|
||||||
|
async def deploy_webhook(
|
||||||
|
request: Request,
|
||||||
|
x_forgejo_signature: Optional[str] = Header(None, alias="X-Forgejo-Signature"),
|
||||||
|
x_forgejo_event: Optional[str] = Header(None, alias="X-Forgejo-Event"),
|
||||||
|
x_gitea_signature: Optional[str] = Header(None, alias="X-Gitea-Signature"),
|
||||||
|
x_gitea_event: Optional[str] = Header(None, alias="X-Gitea-Event"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle Forgejo/Gitea push webhook.
|
||||||
|
Triggers schema sync on push to main branch.
|
||||||
|
"""
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# Use Forgejo or Gitea headers (Forgejo is a Gitea fork)
|
||||||
|
signature = x_forgejo_signature or x_gitea_signature
|
||||||
|
event = x_forgejo_event or x_gitea_event
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
if not verify_signature(body, signature or ""):
|
||||||
|
logger.warning("Invalid webhook signature")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
# Parse payload
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||||
|
|
||||||
|
# Only process push events
|
||||||
|
if event != "push":
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": f"Event type '{event}' not handled"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Only process pushes to main branch
|
||||||
|
ref = payload.get("ref", "")
|
||||||
|
if ref not in ["refs/heads/main", "refs/heads/master"]:
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": f"Push to non-main branch: {ref}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check if schemas changed
|
||||||
|
commits = payload.get("commits", [])
|
||||||
|
schema_changed = False
|
||||||
|
for commit in commits:
|
||||||
|
modified = commit.get("modified", []) + commit.get("added", []) + commit.get("removed", [])
|
||||||
|
for path in modified:
|
||||||
|
if path.startswith("schemas/20251121/linkml/"):
|
||||||
|
schema_changed = True
|
||||||
|
break
|
||||||
|
if schema_changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not schema_changed:
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": "No schema changes detected"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Acquire lock to prevent concurrent deployments
|
||||||
|
if deploy_lock.locked():
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "busy",
|
||||||
|
"message": "Deployment already in progress"
|
||||||
|
}, status_code=409)
|
||||||
|
|
||||||
|
async with deploy_lock:
|
||||||
|
logger.info(f"Starting deployment for push to {ref}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await sync_schemas()
|
||||||
|
success = all(r.get("success", False) for r in results.values())
|
||||||
|
|
||||||
|
return DeployResult(
|
||||||
|
success=success,
|
||||||
|
message="Schema sync completed" if success else "Schema sync failed",
|
||||||
|
details=results,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Deployment failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/webhook/deploy/manual")
|
||||||
|
async def manual_deploy(request: Request):
|
||||||
|
"""
|
||||||
|
Manual deployment trigger (for testing or forced sync).
|
||||||
|
Requires a simple auth token.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
expected = f"Bearer {WEBHOOK_SECRET}"
|
||||||
|
|
||||||
|
if WEBHOOK_SECRET and auth != expected:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
if deploy_lock.locked():
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "busy",
|
||||||
|
"message": "Deployment already in progress"
|
||||||
|
}, status_code=409)
|
||||||
|
|
||||||
|
async with deploy_lock:
|
||||||
|
logger.info("Starting manual deployment")
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await sync_schemas()
|
||||||
|
success = all(r.get("success", False) for r in results.values())
|
||||||
|
|
||||||
|
return DeployResult(
|
||||||
|
success=success,
|
||||||
|
message="Manual schema sync completed" if success else "Manual sync failed",
|
||||||
|
details=results,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Manual deployment failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=8099)
|
||||||
133
infrastructure/scripts/setup-deploy-webhook.sh
Executable file
133
infrastructure/scripts/setup-deploy-webhook.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Setup script for GLAM deploy webhook on the server
|
||||||
|
# Run this on the Hetzner server as root
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== GLAM Deploy Webhook Setup ==="
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
WEBHOOK_SECRET="${1:-$(openssl rand -hex 32)}"
|
||||||
|
GLAM_USER="glam"
|
||||||
|
SCRIPTS_DIR="/var/lib/glam/scripts"
|
||||||
|
REPO_DIR="/var/lib/glam/repo"
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
echo "Creating directories..."
|
||||||
|
mkdir -p "$SCRIPTS_DIR"
|
||||||
|
mkdir -p "$REPO_DIR"
|
||||||
|
|
||||||
|
# Clone/update the repo
|
||||||
|
if [ -d "$REPO_DIR/.git" ]; then
|
||||||
|
echo "Updating existing repo..."
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
else
|
||||||
|
echo "Cloning repository..."
|
||||||
|
git clone https://git.bronhouder.nl/kempersc/glam.git "$REPO_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
echo "Installing Python dependencies..."
|
||||||
|
pip3 install fastapi uvicorn pydantic --quiet
|
||||||
|
|
||||||
|
# Copy webhook script
|
||||||
|
echo "Deploying webhook script..."
|
||||||
|
cp "$REPO_DIR/infrastructure/scripts/deploy-webhook.py" "$SCRIPTS_DIR/"
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
echo "Creating systemd service..."
|
||||||
|
cat > /etc/systemd/system/deploy-webhook.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=GLAM Deploy Webhook Service
|
||||||
|
Documentation=https://git.bronhouder.nl/kempersc/glam
|
||||||
|
After=network.target caddy.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$GLAM_USER
|
||||||
|
Group=$GLAM_USER
|
||||||
|
WorkingDirectory=$SCRIPTS_DIR
|
||||||
|
Environment="WEBHOOK_SECRET=$WEBHOOK_SECRET"
|
||||||
|
ExecStart=/usr/bin/python3 -m uvicorn deploy-webhook:app --host 127.0.0.1 --port 8099
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Security
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/glam /var/www/glam-frontend
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
echo "Setting ownership..."
|
||||||
|
chown -R $GLAM_USER:$GLAM_USER "$REPO_DIR"
|
||||||
|
chown -R $GLAM_USER:$GLAM_USER "$SCRIPTS_DIR"
|
||||||
|
|
||||||
|
# Add webhook endpoint to Caddy
|
||||||
|
echo "Checking Caddy configuration..."
|
||||||
|
if ! grep -q "/webhook/deploy" /etc/caddy/Caddyfile; then
|
||||||
|
echo "Adding webhook endpoint to Caddy..."
|
||||||
|
# Insert webhook handler after /health in bronhouder.nl block
|
||||||
|
# This is a simple sed approach - may need manual adjustment
|
||||||
|
sed -i '/bronhouder.nl, www.bronhouder.nl/,/handle \/health/a\\n\t# Webhook endpoint for Forgejo push events\n\thandle /webhook/deploy* {\n\t\treverse_proxy 127.0.0.1:8099 {\n\t\t\ttransport http {\n\t\t\t\tread_timeout 120s\n\t\t\t\twrite_timeout 120s\n\t\t\t}\n\t\t}\n\t}' /etc/caddy/Caddyfile || {
|
||||||
|
echo "WARNING: Could not auto-add webhook to Caddyfile"
|
||||||
|
echo "Please manually add the following to bronhouder.nl block:"
|
||||||
|
cat << 'CADDY'
|
||||||
|
# Webhook endpoint for Forgejo push events
|
||||||
|
handle /webhook/deploy* {
|
||||||
|
reverse_proxy 127.0.0.1:8099 {
|
||||||
|
transport http {
|
||||||
|
read_timeout 120s
|
||||||
|
write_timeout 120s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CADDY
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reload systemd and start service
|
||||||
|
echo "Starting services..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable deploy-webhook
|
||||||
|
systemctl restart deploy-webhook
|
||||||
|
|
||||||
|
# Reload Caddy if config was changed
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy || {
|
||||||
|
echo "WARNING: Caddy config validation failed. Please fix manually."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initial schema sync
|
||||||
|
echo "Running initial schema sync..."
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
rsync -av --delete \
|
||||||
|
--exclude "*.pyc" \
|
||||||
|
--exclude "__pycache__" \
|
||||||
|
--exclude ".git" \
|
||||||
|
"schemas/20251121/linkml/" \
|
||||||
|
"/var/www/glam-frontend/schemas/20251121/linkml/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Setup Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Webhook Secret: $WEBHOOK_SECRET"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Go to https://git.bronhouder.nl/kempersc/glam/settings/hooks"
|
||||||
|
echo "2. Add a new webhook:"
|
||||||
|
echo " - Target URL: https://bronhouder.nl/webhook/deploy"
|
||||||
|
echo " - HTTP Method: POST"
|
||||||
|
echo " - Content Type: application/json"
|
||||||
|
echo " - Secret: $WEBHOOK_SECRET"
|
||||||
|
echo " - Trigger On: Push Events"
|
||||||
|
echo " - Branch filter: main"
|
||||||
|
echo ""
|
||||||
|
echo "Test with: curl -X POST https://bronhouder.nl/webhook/deploy/manual -H 'Authorization: Bearer $WEBHOOK_SECRET'"
|
||||||
32
infrastructure/sync-schemas.sh
Executable file
32
infrastructure/sync-schemas.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Sync LinkML schemas from source to frontend public directory
|
||||||
|
# This ensures the frontend serves the latest schemas during development and in production builds
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
SOURCE_DIR="$PROJECT_ROOT/schemas/20251121/linkml"
|
||||||
|
DEST_DIR="$PROJECT_ROOT/frontend/public/schemas/20251121/linkml"
|
||||||
|
|
||||||
|
echo "Syncing LinkML schemas..."
|
||||||
|
echo " Source: $SOURCE_DIR"
|
||||||
|
echo " Dest: $DEST_DIR"
|
||||||
|
|
||||||
|
# Ensure destination directory exists
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
|
||||||
|
# Rsync with delete to remove old files
|
||||||
|
rsync -av --delete \
|
||||||
|
--exclude "*.pyc" \
|
||||||
|
--exclude "__pycache__" \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SOURCE_DIR/" \
|
||||||
|
"$DEST_DIR/"
|
||||||
|
|
||||||
|
echo "Schema sync complete!"
|
||||||
|
|
||||||
|
# Count files synced
|
||||||
|
TOTAL=$(find "$DEST_DIR" -type f -name "*.yaml" | wc -l | tr -d ' ')
|
||||||
|
echo " Total YAML files: $TOTAL"
|
||||||
25
infrastructure/systemd/deploy-webhook.service
Normal file
25
infrastructure/systemd/deploy-webhook.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=GLAM Deploy Webhook Service
|
||||||
|
Documentation=https://git.bronhouder.nl/kempersc/glam
|
||||||
|
After=network.target caddy.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=glam
|
||||||
|
Group=glam
|
||||||
|
WorkingDirectory=/var/lib/glam/scripts
|
||||||
|
Environment="WEBHOOK_SECRET="
|
||||||
|
ExecStart=/usr/bin/python3 -m uvicorn deploy-webhook:app --host 127.0.0.1 --port 8099
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Security
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/glam /var/www/glam-frontend
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Loading…
Reference in a new issue