diff --git a/app/forms/project.py b/app/forms/project.py index 99e39d9..abfaa29 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -587,6 +587,25 @@ def validate_confirm(self, field): raise ValidationError(_("Project name confirmation did not match.")) +class ProjectWebhooksForm(StarletteForm): + webhook_url = StringField( + _l("Webhook URL"), + validators=[ + Optional(), + Length(max=2048), + Regexp( + r"^https?://", + message=_l("URL must start with http:// or https://"), + ), + ], + ) + webhook_secret = StringField( + _l("Secret"), + validators=[Optional(), Length(max=255)], + ) + webhook_events = FieldList(StringField(), min_entries=0) + + class ProjectDeployForm(StarletteForm): commit = HiddenField(_l("Commit"), validators=[DataRequired()]) submit = SubmitField(_l("Deploy")) @@ -605,3 +624,33 @@ class ProjectPromoteDeploymentForm(StarletteForm): environment_id = HiddenField(_l("Environment ID"), validators=[DataRequired()]) deployment_id = HiddenField(_l("Deployment ID"), validators=[DataRequired()]) submit = SubmitField(_l("Promote")) + + +class DeployTokenForm(StarletteForm): + token_id = HiddenField() + name = StringField( + _l("Name"), + validators=[DataRequired(), Length(min=1, max=100)], + ) + environment_id = SelectField(_l("Environment"), choices=[]) + + def __init__(self, *args, project: Project | None = None, **kwargs): + super().__init__(*args, **kwargs) + if project: + self.environment_id.choices = [("", _l("All environments"))] + [ + (env["id"], env["name"]) for env in project.active_environments + ] + + +class DeleteDeployTokenForm(StarletteForm): + token_id = HiddenField(validators=[DataRequired()]) + confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) + submit = SubmitField(_l("Revoke")) + + def __init__(self, *args, token_name: str | None = None, **kwargs): + super().__init__(*args, **kwargs) + self.token_name = token_name + + def validate_confirm(self, field): + if self.token_name and field.data != self.token_name: + raise ValidationError(_("Token name confirmation did not match.")) diff --git a/app/forms/team.py b/app/forms/team.py index 26744eb..e7c8d92 100644 --- a/app/forms/team.py +++ b/app/forms/team.py @@ -6,8 +6,17 @@ FileField, HiddenField, SelectField, + SelectMultipleField, + FieldList, +) +from wtforms.validators import ( + ValidationError, + DataRequired, + Length, + Regexp, + Email, + Optional, ) -from wtforms.validators import ValidationError, DataRequired, Length, Regexp, Email from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -226,3 +235,50 @@ class TeamLeaveForm(StarletteForm): class TeamInviteAcceptForm(StarletteForm): invite_id = HiddenField(validators=[DataRequired()]) submit = SubmitField(_l("Accept")) + + +WEBHOOK_EVENTS = [ + ("started", _l("Deployment Started")), + ("succeeded", _l("Deployment Succeeded")), + ("failed", _l("Deployment Failed")), + ("canceled", _l("Deployment Canceled")), +] + + +class TeamWebhookForm(StarletteForm): + webhook_id = HiddenField() + name = StringField( + _l("Name"), + validators=[DataRequired(), Length(min=1, max=100)], + ) + url = StringField( + _l("URL"), + validators=[ + DataRequired(), + Length(max=2048), + Regexp( + r"^https?://", + message=_l("URL must start with http:// or https://"), + ), + ], + ) + secret = StringField( + _l("Secret"), + validators=[Optional(), Length(max=255)], + ) + events = FieldList(StringField(), min_entries=0) + project_ids = FieldList(StringField(), min_entries=0) + + +class TeamDeleteWebhookForm(StarletteForm): + webhook_id = HiddenField(validators=[DataRequired()]) + confirm = StringField(_l("Confirmation"), validators=[DataRequired()]) + submit = SubmitField(_l("Delete")) + + def __init__(self, *args, webhook_name: str | None = None, **kwargs): + super().__init__(*args, **kwargs) + self.webhook_name = webhook_name + + def validate_confirm(self, field): + if self.webhook_name and field.data != self.webhook_name: + raise ValidationError(_("Webhook name confirmation did not match.")) diff --git a/app/main.py b/app/main.py index b7f670e..d3aef2a 100644 --- a/app/main.py +++ b/app/main.py @@ -17,7 +17,7 @@ from db import get_db, AsyncSessionLocal from dependencies import get_current_user, TemplateResponse from models import User, Team, Deployment, Project -from routers import auth, project, github, google, team, user, event, admin +from routers import auth, project, github, google, team, user, event, admin, api from services.loki import LokiService settings = get_settings() @@ -158,6 +158,7 @@ async def root( app.include_router(google.router) app.include_router(team.router) app.include_router(event.router) +app.include_router(api.router) @app.exception_handler(404) diff --git a/app/migrations/versions/c1a2b3d4e5f6_webhooks_and_deploy_tokens.py b/app/migrations/versions/c1a2b3d4e5f6_webhooks_and_deploy_tokens.py new file mode 100644 index 0000000..c00d454 --- /dev/null +++ b/app/migrations/versions/c1a2b3d4e5f6_webhooks_and_deploy_tokens.py @@ -0,0 +1,108 @@ +"""webhooks_and_deploy_tokens + +Revision ID: c1a2b3d4e5f6 +Revises: 87a893d57c86 +Create Date: 2025-12-30 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c1a2b3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "87a893d57c86" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Create team_webhook table + op.create_table( + "team_webhook", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("team_id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("url", sa.String(length=2048), nullable=False), + sa.Column("secret", sa.String(length=512), nullable=True), + sa.Column("events", sa.JSON(), nullable=False), + sa.Column("project_ids", sa.JSON(), nullable=True), + sa.Column( + "status", + sa.Enum("active", "disabled", name="team_webhook_status"), + nullable=False, + ), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["team_id"], + ["team.id"], + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], ["user.id"], ondelete="SET NULL", use_alter=True + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_team_webhook_team_id"), "team_webhook", ["team_id"], unique=False + ) + op.create_index( + op.f("ix_team_webhook_created_at"), "team_webhook", ["created_at"], unique=False + ) + op.create_index( + op.f("ix_team_webhook_updated_at"), "team_webhook", ["updated_at"], unique=False + ) + + # Create deploy_token table + op.create_table( + "deploy_token", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("project_id", sa.String(length=32), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("token", sa.String(length=128), nullable=False), + sa.Column("environment_id", sa.String(length=8), nullable=True), + sa.Column( + "status", + sa.Enum("active", "revoked", name="deploy_token_status"), + nullable=False, + ), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["project_id"], + ["project.id"], + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], ["user.id"], ondelete="SET NULL", use_alter=True + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_index( + op.f("ix_deploy_token_project_id"), "deploy_token", ["project_id"], unique=False + ) + op.create_index( + op.f("ix_deploy_token_created_at"), "deploy_token", ["created_at"], unique=False + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Drop deploy_token table + op.drop_index(op.f("ix_deploy_token_created_at"), table_name="deploy_token") + op.drop_index(op.f("ix_deploy_token_project_id"), table_name="deploy_token") + op.drop_table("deploy_token") + op.execute("DROP TYPE IF EXISTS deploy_token_status") + + # Drop team_webhook table + op.drop_index(op.f("ix_team_webhook_updated_at"), table_name="team_webhook") + op.drop_index(op.f("ix_team_webhook_created_at"), table_name="team_webhook") + op.drop_index(op.f("ix_team_webhook_team_id"), table_name="team_webhook") + op.drop_table("team_webhook") + op.execute("DROP TYPE IF EXISTS team_webhook_status") diff --git a/app/models.py b/app/models.py index dc8116f..35d3c09 100644 --- a/app/models.py +++ b/app/models.py @@ -849,3 +849,135 @@ class Allowlist(Base): @override def __repr__(self): return f"" + + +class TeamWebhook(Base): + """Team-level webhook configuration for deployment events. + + Webhooks can be scoped to specific projects or apply to all projects in the team. + """ + + __tablename__: str = "team_webhook" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, default=lambda: token_hex(16) + ) + team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + url: Mapped[str] = mapped_column(String(2048), nullable=False) + _secret: Mapped[str | None] = mapped_column("secret", String(512), nullable=True) + events: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + project_ids: Mapped[list[str] | None] = mapped_column( + JSON, nullable=True + ) # None = all projects + status: Mapped[str] = mapped_column( + SQLAEnum("active", "disabled", name="team_webhook_status"), + nullable=False, + default="active", + ) + created_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", use_alter=True, ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + index=True, nullable=False, default=utc_now + ) + updated_at: Mapped[datetime] = mapped_column( + index=True, nullable=False, default=utc_now, onupdate=utc_now + ) + + # Relationships + team: Mapped[Team] = relationship() + created_by_user: Mapped[User | None] = relationship( + foreign_keys=[created_by_user_id] + ) + + @property + def secret(self) -> str | None: + if self._secret: + fernet = get_fernet() + return fernet.decrypt(self._secret.encode()).decode() + return None + + @secret.setter + def secret(self, value: str | None): + if value: + fernet = get_fernet() + self._secret = fernet.encrypt(value.encode()).decode() + else: + self._secret = None + + def applies_to_project(self, project_id: str) -> bool: + """Check if this webhook applies to the given project.""" + if self.project_ids is None: + return True # All projects + return project_id in self.project_ids + + @override + def __repr__(self): + return f"" + + +class DeployToken(Base): + """Deploy token for triggering deployments via API/webhook. + + Tokens can be scoped to specific environments or all environments. + """ + + __tablename__: str = "deploy_token" + + id: Mapped[str] = mapped_column( + String(32), primary_key=True, default=lambda: token_hex(16) + ) + project_id: Mapped[str] = mapped_column(ForeignKey("project.id"), index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + _token: Mapped[str] = mapped_column( + "token", String(128), nullable=False, unique=True + ) + environment_id: Mapped[str | None] = mapped_column( + String(8), nullable=True + ) # None = all environments + status: Mapped[str] = mapped_column( + SQLAEnum("active", "revoked", name="deploy_token_status"), + nullable=False, + default="active", + ) + last_used_at: Mapped[datetime | None] = mapped_column(nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", use_alter=True, ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + index=True, nullable=False, default=utc_now + ) + + # Relationships + project: Mapped[Project] = relationship() + created_by_user: Mapped[User | None] = relationship( + foreign_keys=[created_by_user_id] + ) + + @classmethod + def generate_token(cls) -> str: + """Generate a secure random token.""" + return f"dp_{token_hex(32)}" + + @property + def token(self) -> str: + """Get the token (stored as hash, so this returns the hash).""" + return self._token + + @classmethod + def hash_token(cls, raw_token: str) -> str: + """Hash a raw token for storage.""" + import hashlib + + return hashlib.sha256(raw_token.encode()).hexdigest() + + def can_deploy_environment(self, environment_id: str) -> bool: + """Check if this token can deploy to the given environment.""" + if self.environment_id is None: + return True # All environments + return self.environment_id == environment_id + + @override + def __repr__(self): + return f"" diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..375ddef --- /dev/null +++ b/app/routers/api.py @@ -0,0 +1,282 @@ +"""API router for deploy tokens (inbound deployment webhooks).""" + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Request, HTTPException, Header +from fastapi.responses import JSONResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from arq.connections import ArqRedis + +from db import get_db +from dependencies import get_job_queue +from models import DeployToken, Project, Deployment, utc_now + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["api"]) + + +@router.post("/deploy", name="api_deploy") +async def trigger_deploy( + request: Request, + authorization: str | None = Header(None), + x_deploy_token: str | None = Header(None, alias="X-Deploy-Token"), + db: AsyncSession = Depends(get_db), + job_queue: ArqRedis = Depends(get_job_queue), +): + """Trigger a deployment using a deploy token. + + This endpoint allows external systems to trigger deployments via API. + + Authorization can be provided via: + - Authorization header: `Bearer dp_xxxxx` + - X-Deploy-Token header: `dp_xxxxx` + + Optional request body (JSON): + ```json + { + "branch": "main", + "environment_id": "prod", + "commit_sha": "abc123..." + } + ``` + + If branch/commit are not provided, the latest commit from the environment's + configured branch will be used. + """ + # Extract token from headers + raw_token = None + if authorization and authorization.startswith("Bearer "): + raw_token = authorization[7:] + elif x_deploy_token: + raw_token = x_deploy_token + + if not raw_token: + raise HTTPException( + status_code=401, + detail="Missing deploy token. Provide via Authorization header (Bearer token) or X-Deploy-Token header.", + ) + + if not raw_token.startswith("dp_"): + raise HTTPException( + status_code=401, + detail="Invalid token format. Token should start with 'dp_'.", + ) + + # Hash the token to look it up + token_hash = DeployToken.hash_token(raw_token) + + # Find the deploy token + result = await db.execute( + select(DeployToken) + .options(selectinload(DeployToken.project)) + .where( + DeployToken._token == token_hash, + DeployToken.status == "active", + ) + ) + deploy_token = result.scalar_one_or_none() + + if not deploy_token: + raise HTTPException(status_code=401, detail="Invalid or revoked deploy token.") + + project = deploy_token.project + if not project or project.status != "active": + raise HTTPException(status_code=404, detail="Project not found or inactive.") + + # Parse request body + body = {} + try: + if await request.body(): + body = await request.json() + except Exception: + pass + + # Determine environment + environment_id = body.get("environment_id") or deploy_token.environment_id + if not environment_id: + # Default to production + environment_id = "prod" + + # Check if token can deploy to this environment + if not deploy_token.can_deploy_environment(environment_id): + raise HTTPException( + status_code=403, + detail=f"Token not authorized to deploy to environment '{environment_id}'.", + ) + + # Get environment config + environment = project.get_environment_by_id(environment_id) + if not environment: + raise HTTPException( + status_code=404, + detail=f"Environment '{environment_id}' not found.", + ) + + # Determine branch + branch = body.get("branch") or environment.get("branch") + if not branch: + raise HTTPException( + status_code=400, + detail="No branch specified and environment has no default branch.", + ) + + # Determine commit (optional - if not provided, will fetch latest) + commit_sha = body.get("commit_sha") + + # Update last used timestamp + deploy_token.last_used_at = utc_now() + await db.commit() + + # Create deployment + deployment = Deployment( + project=project, + environment_id=environment_id, + branch=branch, + commit_sha=commit_sha or "", # Will be filled by deploy task if empty + trigger="api", + ) + if commit_sha: + deployment.commit_sha = commit_sha + else: + # Need to fetch the latest commit + from services.github_installation import GithubInstallationService + from dependencies import get_github_installation_service + + github_service = get_github_installation_service() + try: + token = await github_service.get_installation_token( + db, project.github_installation_id + ) + if token: + commit_info = await github_service.get_latest_commit( + token, project.repo_full_name, branch + ) + if commit_info: + deployment.commit_sha = commit_info.get("sha", "") + deployment.commit_meta = { + "message": commit_info.get("commit", {}) + .get("message", "") + .split("\n")[0], + "author": commit_info.get("commit", {}) + .get("author", {}) + .get("name", ""), + } + except Exception as e: + logger.warning(f"Failed to fetch commit info: {e}") + + if not deployment.commit_sha: + raise HTTPException( + status_code=400, + detail="Could not determine commit SHA. Please provide commit_sha in request body.", + ) + + db.add(deployment) + await db.commit() + + # Queue deployment job + await job_queue.enqueue_job("deploy_start", deployment.id) + + logger.info( + f"Deployment triggered via API: project={project.name}, " + f"env={environment_id}, branch={branch}, deployment={deployment.id}" + ) + + return JSONResponse( + status_code=202, + content={ + "message": "Deployment queued", + "deployment": { + "id": deployment.id, + "project_id": project.id, + "project_name": project.name, + "environment_id": environment_id, + "environment_name": environment.get("name"), + "branch": branch, + "commit_sha": deployment.commit_sha, + "status": deployment.status, + "url": f"/api/deployments/{deployment.id}", + }, + }, + ) + + +@router.get("/deployments/{deployment_id}", name="api_deployment_status") +async def get_deployment_status( + deployment_id: str, + authorization: str | None = Header(None), + x_deploy_token: str | None = Header(None, alias="X-Deploy-Token"), + db: AsyncSession = Depends(get_db), +): + """Get deployment status. + + Requires a valid deploy token that has access to the deployment's project. + """ + # Extract token from headers + raw_token = None + if authorization and authorization.startswith("Bearer "): + raw_token = authorization[7:] + elif x_deploy_token: + raw_token = x_deploy_token + + if not raw_token: + raise HTTPException(status_code=401, detail="Missing deploy token.") + + if not raw_token.startswith("dp_"): + raise HTTPException(status_code=401, detail="Invalid token format.") + + token_hash = DeployToken.hash_token(raw_token) + + # Find the deploy token + result = await db.execute( + select(DeployToken).where( + DeployToken._token == token_hash, + DeployToken.status == "active", + ) + ) + deploy_token = result.scalar_one_or_none() + + if not deploy_token: + raise HTTPException(status_code=401, detail="Invalid or revoked deploy token.") + + # Get deployment + result = await db.execute( + select(Deployment) + .options(selectinload(Deployment.project)) + .where(Deployment.id == deployment_id) + ) + deployment = result.scalar_one_or_none() + + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found.") + + # Check token has access to this project + if deployment.project_id != deploy_token.project_id: + raise HTTPException( + status_code=403, detail="Token not authorized for this project." + ) + + environment = deployment.project.get_environment_by_id(deployment.environment_id) + + return { + "id": deployment.id, + "project_id": deployment.project_id, + "project_name": deployment.project.name, + "environment_id": deployment.environment_id, + "environment_name": environment.get("name") if environment else None, + "branch": deployment.branch, + "commit_sha": deployment.commit_sha, + "status": deployment.status, + "conclusion": deployment.conclusion, + "trigger": deployment.trigger, + "created_at": deployment.created_at.isoformat() + if deployment.created_at + else None, + "concluded_at": deployment.concluded_at.isoformat() + if deployment.concluded_at + else None, + "url": deployment.url, + } diff --git a/app/routers/project.py b/app/routers/project.py index 5ef2062..2ac34bc 100644 --- a/app/routers/project.py +++ b/app/routers/project.py @@ -35,6 +35,7 @@ User, Team, TeamMember, + DeployToken, utc_now, ) from forms.project import ( @@ -52,6 +53,9 @@ ProjectRemoveDomainForm, ProjectVerifyDomainForm, ProjectResourcesForm, + ProjectWebhooksForm, + DeployTokenForm, + DeleteDeployTokenForm, ) from config import get_settings, Settings from db import get_db @@ -1452,6 +1456,147 @@ async def project_settings( }, ) + # Webhooks + webhook_events = (project.config or {}).get("webhook_events") or [ + "started", + "succeeded", + "failed", + "canceled", + ] + webhooks_form: Any = await ProjectWebhooksForm.from_formdata( + request, + data={ + "webhook_url": (project.config or {}).get("webhook_url", ""), + "webhook_secret": (project.config or {}).get("webhook_secret", ""), + "webhook_events": webhook_events, + }, + ) + + if fragment == "webhooks": + if await webhooks_form.validate_on_submit(): + form_data = await request.form() + selected_events = form_data.getlist("webhook_events") + webhook_events = list(selected_events) if selected_events else [] + project.config = { + **(project.config or {}), + "webhook_url": webhooks_form.webhook_url.data or "", + "webhook_secret": webhooks_form.webhook_secret.data or "", + "webhook_events": webhook_events, + } + await db.commit() + flash(request, _("Webhooks updated."), "success") + + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/partials/_settings-webhooks.html", + context={ + "current_user": current_user, + "team": team, + "project": project, + "webhooks_form": webhooks_form, + "webhook_events": webhook_events, + }, + ) + + # Deploy Tokens + deploy_tokens_result = await db.execute( + select(DeployToken) + .where(DeployToken.project_id == project.id, DeployToken.status == "active") + .order_by(DeployToken.created_at.desc()) + ) + deploy_tokens = list(deploy_tokens_result.scalars().all()) + + token_form: Any = await DeployTokenForm.from_formdata(request, project=project) + delete_token_form: Any = await DeleteDeployTokenForm.from_formdata(request) + new_token_value = None + + if fragment == "add_token": + if await token_form.validate_on_submit(): + try: + raw_token = DeployToken.generate_token() + token_hash = DeployToken.hash_token(raw_token) + + deploy_token = DeployToken( + project_id=project.id, + name=token_form.name.data, + _token=token_hash, + environment_id=token_form.environment_id.data or None, + status="active", + created_by_user_id=current_user.id, + ) + db.add(deploy_token) + await db.commit() + + new_token_value = raw_token + deploy_tokens.insert(0, deploy_token) + flash(request, _("Deploy token created."), "success") + except Exception as e: + await db.rollback() + logger.error(f"Error creating deploy token: {str(e)}") + flash(request, _("Failed to create deploy token."), "error") + + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/partials/_settings-deploy-tokens.html", + context={ + "current_user": current_user, + "team": team, + "project": project, + "deploy_tokens": deploy_tokens, + "token_form": token_form, + "delete_token_form": delete_token_form, + "new_token_value": new_token_value, + }, + ) + + if fragment == "delete_token": + form_data = await request.form() + token_id = form_data.get("token_id") + + if token_id: + token_to_delete = next((t for t in deploy_tokens if t.id == token_id), None) + if token_to_delete: + delete_token_form.token_name = token_to_delete.name + + if await delete_token_form.validate_on_submit(): + try: + result = await db.execute( + select(DeployToken).where( + DeployToken.id == token_id, + DeployToken.project_id == project.id, + ) + ) + token_to_delete = result.scalar_one_or_none() + + if token_to_delete: + token_to_delete.status = "revoked" + await db.commit() + deploy_tokens = [t for t in deploy_tokens if t.id != token_id] + flash(request, _("Deploy token revoked."), "success") + else: + flash(request, _("Token not found."), "error") + except Exception as e: + await db.rollback() + logger.error(f"Error revoking deploy token: {str(e)}") + flash(request, _("Failed to revoke deploy token."), "error") + + if request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="project/partials/_settings-deploy-tokens.html", + context={ + "current_user": current_user, + "team": team, + "project": project, + "deploy_tokens": deploy_tokens, + "token_form": token_form, + "delete_token_form": delete_token_form, + "new_token_value": None, + }, + ) + latest_teams = await get_latest_teams( db=db, current_user=current_user, current_team=team ) @@ -1487,6 +1632,12 @@ async def project_settings( "domains": domains, "server_ip": settings.server_ip, "deploy_domain": settings.deploy_domain, + "webhooks_form": webhooks_form, + "webhook_events": webhook_events, + "deploy_tokens": deploy_tokens, + "token_form": token_form, + "delete_token_form": delete_token_form, + "new_token_value": new_token_value, "colors": COLORS, "presets": settings.presets, "images": settings.images, diff --git a/app/routers/team.py b/app/routers/team.py index fa2613f..3e4a671 100644 --- a/app/routers/team.py +++ b/app/routers/team.py @@ -11,7 +11,16 @@ from datetime import timedelta import resend -from models import Project, Deployment, User, Team, TeamMember, utc_now, TeamInvite +from models import ( + Project, + Deployment, + User, + Team, + TeamMember, + utc_now, + TeamInvite, + TeamWebhook, +) from dependencies import ( get_current_user, get_team_by_slug, @@ -34,6 +43,8 @@ TeamAddMemberForm, TeamDeleteMemberForm, TeamMemberRoleForm, + TeamWebhookForm, + TeamDeleteWebhookForm, ) logger = logging.getLogger(__name__) @@ -459,6 +470,150 @@ async def team_settings( }, ) + # Webhooks + webhook_form: Any = await TeamWebhookForm.from_formdata(request) + delete_webhook_form: Any = await TeamDeleteWebhookForm.from_formdata(request) + + # Get team projects for webhook scoping + projects_result = await db.execute( + select(Project) + .where(Project.team_id == team.id, Project.status != "deleted") + .order_by(Project.name) + ) + projects = projects_result.scalars().all() + + # Get team webhooks + webhooks_result = await db.execute( + select(TeamWebhook) + .where(TeamWebhook.team_id == team.id) + .order_by(TeamWebhook.created_at.desc()) + ) + webhooks = webhooks_result.scalars().all() + + if fragment == "add_webhook": + if await webhook_form.validate_on_submit(): + # Get events from form + events = ( + request._form.getlist("events") if hasattr(request, "_form") else [] + ) + project_ids = ( + request._form.getlist("project_ids") + if hasattr(request, "_form") + else [] + ) + + webhook = TeamWebhook( + team_id=team.id, + name=webhook_form.name.data, + url=webhook_form.url.data, + events=events if events else None, + project_ids=project_ids if project_ids else None, + created_by_user_id=current_user.id, + ) + if webhook_form.secret.data: + webhook.secret = webhook_form.secret.data + db.add(webhook) + await db.commit() + flash( + request, _('Webhook "%(name)s" created.', name=webhook.name), "success" + ) + + # Refresh webhooks list + webhooks_result = await db.execute( + select(TeamWebhook) + .where(TeamWebhook.team_id == team.id) + .order_by(TeamWebhook.created_at.desc()) + ) + webhooks = webhooks_result.scalars().all() + + if fragment == "edit_webhook": + webhook_id = ( + request._form.get("webhook_id") if hasattr(request, "_form") else None + ) + if webhook_id: + webhook = await db.scalar( + select(TeamWebhook).where( + TeamWebhook.id == webhook_id, TeamWebhook.team_id == team.id + ) + ) + if webhook and await webhook_form.validate_on_submit(): + events = ( + request._form.getlist("events") if hasattr(request, "_form") else [] + ) + project_ids = ( + request._form.getlist("project_ids") + if hasattr(request, "_form") + else [] + ) + + webhook.name = webhook_form.name.data + webhook.url = webhook_form.url.data + webhook.events = events if events else None + webhook.project_ids = project_ids if project_ids else None + if webhook_form.secret.data: + webhook.secret = webhook_form.secret.data + await db.commit() + flash( + request, + _('Webhook "%(name)s" updated.', name=webhook.name), + "success", + ) + + # Refresh webhooks list + webhooks_result = await db.execute( + select(TeamWebhook) + .where(TeamWebhook.team_id == team.id) + .order_by(TeamWebhook.created_at.desc()) + ) + webhooks = webhooks_result.scalars().all() + + if fragment == "delete_webhook": + webhook_id = ( + request._form.get("webhook_id") if hasattr(request, "_form") else None + ) + if webhook_id: + webhook = await db.scalar( + select(TeamWebhook).where( + TeamWebhook.id == webhook_id, TeamWebhook.team_id == team.id + ) + ) + if webhook: + delete_webhook_form.webhook_name = webhook.name + if await delete_webhook_form.validate_on_submit(): + await db.delete(webhook) + await db.commit() + flash( + request, + _('Webhook "%(name)s" deleted.', name=webhook.name), + "success", + ) + + # Refresh webhooks list + webhooks_result = await db.execute( + select(TeamWebhook) + .where(TeamWebhook.team_id == team.id) + .order_by(TeamWebhook.created_at.desc()) + ) + webhooks = webhooks_result.scalars().all() + + if fragment in ( + "add_webhook", + "edit_webhook", + "delete_webhook", + ) and request.headers.get("HX-Request"): + return TemplateResponse( + request=request, + name="team/partials/_settings-webhooks.html", + context={ + "current_user": current_user, + "team": team, + "webhooks": webhooks, + "projects": projects, + "webhook_form": webhook_form, + "delete_webhook_form": delete_webhook_form, + }, + ) + latest_teams = await get_latest_teams( db=db, current_user=current_user, current_team=team ) @@ -479,6 +634,10 @@ async def team_settings( "member_invites": member_invites, "owner_count": owner_count, "latest_teams": latest_teams, + "webhooks": webhooks, + "projects": projects, + "webhook_form": webhook_form, + "delete_webhook_form": delete_webhook_form, }, ) diff --git a/app/services/deployment.py b/app/services/deployment.py index 432ef7e..0afea40 100644 --- a/app/services/deployment.py +++ b/app/services/deployment.py @@ -10,7 +10,8 @@ from models import Deployment, Alias, Project, User, Domain from utils.environment import get_environment_for_branch -from config import Settings +from config import Settings, get_settings +from services.webhook import send_deployment_webhook logger = logging.getLogger(__name__) @@ -257,6 +258,22 @@ async def cancel( fields, ) await redis_client.xadd(f"stream:project:{project.id}:updates", fields) + + # Send webhook notification for deployment canceled + try: + settings = get_settings() + await send_deployment_webhook( + project=project, + deployment=deployment, + event="canceled", + url_scheme=settings.url_scheme, + deploy_domain=settings.deploy_domain, + db=db, + ) + except Exception as e: + logger.warning( + f"Webhook delivery failed for deployment {deployment.id}: {e}" + ) else: logger.error(f"Error aborting deployment {deployment.id}.") raise Exception("Error aborting deployment.") diff --git a/app/services/webhook.py b/app/services/webhook.py new file mode 100644 index 0000000..7a2dc30 --- /dev/null +++ b/app/services/webhook.py @@ -0,0 +1,213 @@ +import hmac +import hashlib +import json +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import httpx + +from models import Deployment, Project + +if TYPE_CHECKING: + from models import TeamWebhook + from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +WEBHOOK_EVENTS = ["started", "succeeded", "failed", "canceled"] + + +def _build_deployment_payload( + project: Project, + deployment: Deployment, + event: str, + url_scheme: str, + deploy_domain: str, +) -> dict: + """Build the webhook payload for a deployment event.""" + environment = project.get_environment_by_id(deployment.environment_id) + + return { + "event": f"deployment.{event}", + "timestamp": datetime.now(timezone.utc).isoformat(), + "project": { + "id": project.id, + "name": project.name, + "slug": project.slug, + "repo_full_name": project.repo_full_name, + }, + "deployment": { + "id": deployment.id, + "status": deployment.status, + "conclusion": deployment.conclusion, + "branch": deployment.branch, + "commit_sha": deployment.commit_sha, + "commit_message": (deployment.commit_meta or {}).get("message", ""), + "commit_author": (deployment.commit_meta or {}).get("author", ""), + "environment": { + "id": deployment.environment_id, + "name": environment.get("name") if environment else None, + "slug": environment.get("slug") if environment else None, + }, + "trigger": deployment.trigger, + "url": f"{url_scheme}://{deployment.slug}.{deploy_domain}" + if deploy_domain + else None, + "created_at": deployment.created_at.isoformat() + if deployment.created_at + else None, + "concluded_at": deployment.concluded_at.isoformat() + if deployment.concluded_at + else None, + }, + } + + +async def _deliver_webhook( + url: str, + payload: dict, + event: str, + delivery_id: str, + secret: str | None = None, +) -> bool: + """Deliver a webhook to a URL. + + Returns True if delivery was successful, False otherwise. + """ + headers = { + "Content-Type": "application/json", + "User-Agent": "devpush-webhook/1.0", + "X-DevPush-Event": f"deployment.{event}", + "X-DevPush-Delivery": delivery_id, + } + + payload_bytes = json.dumps(payload, separators=(",", ":")).encode() + + if secret: + signature = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest() + headers["X-DevPush-Signature"] = f"sha256={signature}" + + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post( + url, + content=payload_bytes, + headers=headers, + ) + if response.status_code >= 400: + logger.warning( + f"Webhook delivery failed to {url}: HTTP {response.status_code}" + ) + return False + else: + logger.info( + f"Webhook delivered to {url}: event={event}, status={response.status_code}" + ) + return True + except Exception as e: + logger.warning(f"Webhook delivery failed to {url}: {e}") + return False + + +async def send_deployment_webhook( + project: Project, + deployment: Deployment, + event: str, + url_scheme: str = "https", + deploy_domain: str = "", + db: "AsyncSession | None" = None, +) -> None: + """Send webhook notifications for a deployment event. + + This sends notifications to: + 1. Project-level webhook (if configured in project.config) + 2. Team-level webhooks (if any are configured and apply to this project) + + Args: + project: The project the deployment belongs to + deployment: The deployment that triggered the event + event: The event type (started, succeeded, failed, canceled) + url_scheme: URL scheme for deployment URLs (http/https) + deploy_domain: The deployment domain for constructing URLs + db: Optional database session for fetching team webhooks + """ + payload = _build_deployment_payload( + project, deployment, event, url_scheme, deploy_domain + ) + + # Send to project-level webhook (backward compatible) + config = project.config or {} + webhook_url = config.get("webhook_url") + if webhook_url: + webhook_events = config.get("webhook_events") or WEBHOOK_EVENTS + if event in webhook_events: + webhook_secret = config.get("webhook_secret") + await _deliver_webhook( + url=webhook_url, + payload=payload, + event=event, + delivery_id=deployment.id, + secret=webhook_secret, + ) + + # Send to team-level webhooks + if db: + await send_team_webhooks( + db=db, + team_id=project.team_id, + project_id=project.id, + event=event, + payload=payload, + delivery_id=deployment.id, + ) + + +async def send_team_webhooks( + db: "AsyncSession", + team_id: str, + project_id: str, + event: str, + payload: dict, + delivery_id: str, +) -> None: + """Send webhook notifications to all applicable team webhooks. + + Args: + db: Database session + team_id: The team ID + project_id: The project ID + event: The event type + payload: The webhook payload + delivery_id: Unique delivery ID + """ + from sqlalchemy import select + from models import TeamWebhook + + # Get all active team webhooks + result = await db.execute( + select(TeamWebhook).where( + TeamWebhook.team_id == team_id, + TeamWebhook.status == "active", + ) + ) + webhooks = result.scalars().all() + + for webhook in webhooks: + # Check if webhook applies to this project + if not webhook.applies_to_project(project_id): + continue + + # Check if webhook is configured for this event + webhook_events = webhook.events or WEBHOOK_EVENTS + if event not in webhook_events: + continue + + # Deliver the webhook + await _deliver_webhook( + url=webhook.url, + payload=payload, + event=event, + delivery_id=f"{delivery_id}-{webhook.id[:8]}", + secret=webhook.secret, + ) diff --git a/app/templates/project/pages/settings.html b/app/templates/project/pages/settings.html index 6e8e0c3..46af131 100644 --- a/app/templates/project/pages/settings.html +++ b/app/templates/project/pages/settings.html @@ -19,6 +19,8 @@

{{ _('Environments') }} {{ _('Environment variables') }} {{ _('Domains') }} + {{ _('Webhooks') }} + {{ _('Deploy Tokens') }} {{ _('Build & Deploy') }} {% if allow_custom_resources %} {{ _('Resources') }} @@ -49,6 +51,16 @@

{% include "project/partials/_settings-domains.html" %} + {# WEBHOOKS #} +
+ {% include "project/partials/_settings-webhooks.html" %} +
+ + {# DEPLOY TOKENS #} +
+ {% include "project/partials/_settings-deploy-tokens.html" %} +
+ {# BUILD & DEPLOY #}
{% include "project/partials/_settings-build-and-deploy.html" %} diff --git a/app/templates/project/partials/_settings-deploy-tokens.html b/app/templates/project/partials/_settings-deploy-tokens.html new file mode 100644 index 0000000..8c63222 --- /dev/null +++ b/app/templates/project/partials/_settings-deploy-tokens.html @@ -0,0 +1,231 @@ +{% from "macros/dialog.html" import dialog %} + +
+
+

{{ _('Deploy Tokens') }}

+

{{ _('Create tokens to trigger deployments via API.') }}

+
+ +
+ {% if deploy_tokens %} +
+ + + + + + + + + + + + + {% for token in deploy_tokens %} + + + + + + + + + {% endfor %} + +
{{ _('Name') }}{{ _('Environment') }}{{ _('Last Used') }}{{ _('Created') }}{{ _('Status') }}
{{ token.name }} + {% if token.environment_id %} + {% set env = project.get_environment_by_id(token.environment_id) %} + {% if env %} + {{ env.name }} + {% else %} + {{ token.environment_id }} + {% endif %} + {% else %} + {{ _('All environments') }} + {% endif %} + + {% if token.last_used_at %} + {{ token.last_used_at.strftime('%b %d, %Y %H:%M') }} + {% else %} + {{ _('Never') }} + {% endif %} + + {{ token.created_at.strftime('%b %d, %Y') }} + + {% if token.status == 'active' %} + {{ _('Active') }} + {% else %} + {{ _('Revoked') }} + {% endif %} + + {% set delete_trigger %} + {% include "icons/trash-2.svg" %} + {% endset %} + {% call dialog( + id="revoke-token-" ~ token.id, + trigger=delete_trigger, + title=_('Revoke Token "%(name)s"?', name=token.name), + trigger_attrs={ + "class": "btn-icon-ghost text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20", + "aria-label": _('Revoke'), + "data-tooltip": _('Revoke'), + "data-align": "end", + }, + dialog_attrs={"class": "max-w-md whitespace-normal text-left"}, + close_button=false, + close_on_overlay_click=false + ) %} +
+

{{ _("This will permanently revoke the token. Any systems using this token will no longer be able to trigger deployments.") }}

+

{{ _('To confirm, enter the token name "%(name)s" below.', name=token.name) | safe }}

+
+ {{ delete_token_form.csrf_token }} + +
+ {{ delete_token_form.confirm( + class_="w-full input", + placeholder=_("Type the token name to confirm"), + ** {"@input": "isValid = $event.target.value === '" ~ token.name ~ "'"} + ) }} +
+
+ + +
+
+
+ {% endcall %} +
+
+ {% else %} +
+

{{ _('No deploy tokens created yet.') }}

+
+ {% endif %} + + {# Show newly created token #} + {% if new_token_value %} +
+

{{ _('Token created successfully!') }}

+

{{ _('Copy this token now. You won\'t be able to see it again.') }}

+
+ {{ new_token_value }} + +
+
+ {% endif %} + +
+

{{ _('Create a new token') }}

+ +
+ {{ token_form.csrf_token }} + +
+
+ {{ token_form.name.label(class="label") }} + {{ token_form.name(class="input", placeholder=_("e.g., CI/CD Pipeline"), **{"x-model": "name"}) }} + {% for error in token_form.name.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ {{ token_form.environment_id.label(class="label") }} + {{ token_form.environment_id(class="select w-full") }} +

{{ _('Limit which environments this token can deploy to.') }}

+
+
+ +
+ +
+
+
+ + {# API documentation #} +
+ {{ _('API Usage') }} +
+
+

{{ _('Trigger a Deployment') }}

+
curl -X POST {{ request.url.scheme }}://{{ request.url.netloc }}/api/deploy \
+  -H "Authorization: Bearer dp_your_token_here" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "branch": "main",
+    "environment_id": "prod"
+  }'
+
+
+

{{ _('Check Deployment Status') }}

+
curl {{ request.url.scheme }}://{{ request.url.netloc }}/api/deployments/{deployment_id} \
+  -H "Authorization: Bearer dp_your_token_here"
+
+
+

{{ _('Request Body (Optional)') }}

+
    +
  • branch: {{ _('Branch to deploy (defaults to environment\'s configured branch)') }}
  • +
  • environment_id: {{ _('Environment to deploy to (defaults to token\'s environment or "prod")') }}
  • +
  • commit_sha: {{ _('Specific commit to deploy (defaults to latest on branch)') }}
  • +
+
+
+

{{ _('Response') }}

+
{
+  "message": "Deployment queued",
+  "deployment": {
+    "id": "abc123...",
+    "project_id": "{{ project.id }}",
+    "project_name": "{{ project.name }}",
+    "environment_id": "prod",
+    "environment_name": "Production",
+    "branch": "main",
+    "commit_sha": "...",
+    "status": "queued",
+    "url": "/api/deployments/abc123..."
+  }
+}
+
+
+
+
+ +
+
+ {% include "icons/loader.svg" %} + {{ _("Saving") }} +
+
+
diff --git a/app/templates/project/partials/_settings-webhooks.html b/app/templates/project/partials/_settings-webhooks.html new file mode 100644 index 0000000..8663fb3 --- /dev/null +++ b/app/templates/project/partials/_settings-webhooks.html @@ -0,0 +1,126 @@ +{% set webhook_event_labels = { + "started": _("Deployment started"), + "succeeded": _("Deployment succeeded"), + "failed": _("Deployment failed"), + "canceled": _("Deployment canceled"), +} %} + +
+ {{ webhooks_form.csrf_token(id=False) }} +
+

{{ _('Webhooks') }}

+

{{ _('Receive HTTP notifications when deployment events occur.') }}

+
+ +
+
+ + + {% for error in webhooks_form.webhook_url.errors %} +

{{ error }}

+ {% endfor %} +

{{ _('We\'ll send a POST request to this URL when selected events occur.') }}

+
+ +
+ + + {% for error in webhooks_form.webhook_secret.errors %} +

{{ error }}

+ {% endfor %} +

{{ _('If provided, we\'ll sign payloads with HMAC-SHA256. Verify using the X-DevPush-Signature header.') }}

+
+ +
+ {{ _('Events') }} +

{{ _('Select which events should trigger the webhook.') }}

+
+ {% for event_id, event_label in webhook_event_labels.items() %} + + {% endfor %} +
+
+ + {% if webhooks_form.webhook_url.data %} +
+ {{ _('Payload format') }} +
+
{
+  "event": "deployment.succeeded",
+  "timestamp": "2025-01-01T12:00:00Z",
+  "project": {
+    "id": "{{ project.id }}",
+    "name": "{{ project.name }}",
+    "slug": "{{ project.slug }}",
+    "repo_full_name": "{{ project.repo_full_name }}"
+  },
+  "deployment": {
+    "id": "abc123...",
+    "status": "completed",
+    "conclusion": "succeeded",
+    "branch": "main",
+    "commit_sha": "abc123...",
+    "commit_message": "...",
+    "commit_author": "...",
+    "environment": {
+      "id": "prod",
+      "name": "Production",
+      "slug": "production"
+    },
+    "trigger": "webhook",
+    "url": "https://...",
+    "created_at": "...",
+    "concluded_at": "..."
+  }
+}
+
+

{{ _('Headers: X-DevPush-Event, X-DevPush-Delivery, X-DevPush-Signature (if secret is set).') }}

+
+ {% endif %} +
+ +
+ + +
+
+ +
+
+ {% include "icons/loader.svg" %} + {{ _('Saving') }} +
+
diff --git a/app/templates/team/pages/settings.html b/app/templates/team/pages/settings.html index 848ea24..6b70945 100644 --- a/app/templates/team/pages/settings.html +++ b/app/templates/team/pages/settings.html @@ -16,6 +16,7 @@

@@ -28,6 +29,10 @@

{% include "team/partials/_settings-members.html" %}

+
+ {% include "team/partials/_settings-webhooks.html" %} +
+
{% include "team/partials/_settings-danger.html" %}
diff --git a/app/templates/team/partials/_settings-webhooks.html b/app/templates/team/partials/_settings-webhooks.html new file mode 100644 index 0000000..d730775 --- /dev/null +++ b/app/templates/team/partials/_settings-webhooks.html @@ -0,0 +1,330 @@ +{% from "macros/dialog.html" import dialog %} + +{% set event_labels = { + "started": _("Deployment Started"), + "succeeded": _("Deployment Succeeded"), + "failed": _("Deployment Failed"), + "canceled": _("Deployment Canceled"), +} %} + +
+
+

{{ _('Webhooks') }}

+

{{ _('Configure webhooks to receive notifications when deployments occur.') }}

+
+ +
+ {% if webhooks %} +
+ + + + + + + + + + + + + {% for webhook in webhooks %} + + + + + + + + + {% endfor %} + +
{{ _('Name') }}{{ _('URL') }}{{ _('Events') }}{{ _('Projects') }}{{ _('Status') }}
{{ webhook.name }}{{ webhook.url }} + {% if webhook.events %} + {{ webhook.events | length }} event(s) + {% else %} + {{ _('All events') }} + {% endif %} + + {% if webhook.project_ids %} + {{ webhook.project_ids | length }} project(s) + {% else %} + {{ _('All projects') }} + {% endif %} + + {% if webhook.status == 'active' %} + {{ _('Active') }} + {% else %} + {{ _('Disabled') }} + {% endif %} + + {% set edit_trigger %} + {% include "icons/pencil.svg" %} + {% endset %} + {% call dialog( + id="edit-webhook-" ~ webhook.id, + trigger=edit_trigger, + title=_('Edit Webhook'), + trigger_attrs={ + "class": "btn-icon-ghost", + "aria-label": _('Edit'), + "data-tooltip": _('Edit'), + }, + dialog_attrs={"class": "max-w-lg whitespace-normal text-left"}, + close_button=true, + close_on_overlay_click=false + ) %} +
+ {{ webhook_form.csrf_token }} + + +
+ {{ webhook_form.name.label(class="label") }} + {{ webhook_form.name(class="input", value=webhook.name, placeholder=_("My Webhook")) }} +
+ +
+ {{ webhook_form.url.label(class="label") }} + {{ webhook_form.url(class="input", value=webhook.url, placeholder="https://example.com/webhook") }} +
+ +
+ {{ webhook_form.secret.label(class="label") }} + {{ webhook_form.secret(class="input", value="", placeholder=_("Leave empty to keep existing secret")) }} +

{{ _('Used for HMAC-SHA256 signature verification.') }}

+
+ +
+ +
+ {% for event, label in event_labels.items() %} + + {% endfor %} +
+

{{ _('Leave unchecked to receive all events.') }}

+
+ +
+ +
+ {% for project in projects %} + + {% endfor %} +
+

{{ _('Leave unchecked to receive events for all projects.') }}

+
+ +
+ + +
+
+ {% endcall %} + + {% set delete_trigger %} + {% include "icons/trash-2.svg" %} + {% endset %} + {% call dialog( + id="delete-webhook-" ~ webhook.id, + trigger=delete_trigger, + title=_('Delete Webhook "%(name)s"?', name=webhook.name), + trigger_attrs={ + "class": "btn-icon-ghost text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20", + "aria-label": _('Delete'), + "data-tooltip": _('Delete'), + "data-align": "end", + }, + dialog_attrs={"class": "max-w-md whitespace-normal text-left"}, + close_button=false, + close_on_overlay_click=false + ) %} +
+

{{ _("This will permanently delete the webhook. No further notifications will be sent.") }}

+

{{ _('To confirm, enter the webhook name "%(name)s" below.', name=webhook.name) | safe }}

+
+ {{ delete_webhook_form.csrf_token }} + +
+ {{ delete_webhook_form.confirm( + class_="w-full input", + placeholder=_("Type the webhook name to confirm"), + ** {"@input": "isValid = $event.target.value === '" ~ webhook.name ~ "'"} + ) }} +
+
+ + +
+
+
+ {% endcall %} +
+
+ {% else %} +
+

{{ _('No webhooks configured yet.') }}

+
+ {% endif %} + +
+

{{ _('Add a new webhook') }}

+ +
+ {{ webhook_form.csrf_token }} + +
+
+ {{ webhook_form.name.label(class="label") }} + {{ webhook_form.name(class="input", placeholder=_("My Webhook"), **{"x-model": "name"}) }} + {% for error in webhook_form.name.errors %} +

{{ error }}

+ {% endfor %} +
+ +
+ {{ webhook_form.url.label(class="label") }} + {{ webhook_form.url(class="input", placeholder="https://example.com/webhook", **{"x-model": "url"}) }} + {% for error in webhook_form.url.errors %} +

{{ error }}

+ {% endfor %} +
+
+ +
+ {{ webhook_form.secret.label(class="label") }} + {{ webhook_form.secret(class="input", placeholder=_("Optional: secret for signature verification")) }} +

{{ _('Used for HMAC-SHA256 signature verification.') }}

+
+ +
+ +
+ {% for event, label in event_labels.items() %} + + {% endfor %} +
+

{{ _('Leave unchecked to receive all events.') }}

+
+ +
+ +
+ {% for project in projects %} + + {% endfor %} +
+

{{ _('Leave unchecked to receive events for all projects.') }}

+
+ +
+ +
+
+
+ + {# Payload documentation #} +
+ {{ _('Webhook Payload Format') }} +
+
+

{{ _('Headers') }}

+
    +
  • X-DevPush-Event: {{ _('Event type (e.g., deployment.succeeded)') }}
  • +
  • X-DevPush-Delivery: {{ _('Unique delivery ID') }}
  • +
  • X-DevPush-Signature: {{ _('HMAC-SHA256 signature (if secret is set)') }}
  • +
+
+
+

{{ _('Example Payload') }}

+
{
+  "event": "deployment.succeeded",
+  "timestamp": "2025-01-01T12:00:00Z",
+  "project": {
+    "id": "abc123",
+    "name": "my-project",
+    "slug": "my-project",
+    "repo_full_name": "owner/repo"
+  },
+  "deployment": {
+    "id": "def456",
+    "status": "completed",
+    "conclusion": "succeeded",
+    "branch": "main",
+    "commit_sha": "...",
+    "environment": {
+      "id": "prod",
+      "name": "Production",
+      "slug": "production"
+    },
+    "url": "https://..."
+  }
+}
+
+
+
+
+ +
+
+ {% include "icons/loader.svg" %} + {{ _("Saving") }} +
+
+
diff --git a/app/workers/tasks/deploy.py b/app/workers/tasks/deploy.py index bf023fb..0ea0b46 100644 --- a/app/workers/tasks/deploy.py +++ b/app/workers/tasks/deploy.py @@ -17,6 +17,7 @@ from config import get_settings from arq.connections import ArqRedis from services.deployment import DeploymentService +from services.webhook import send_deployment_webhook logger = logging.getLogger(__name__) @@ -81,6 +82,19 @@ async def deploy_start(ctx, deployment_id: str): fields, ) + # Send webhook notification for deployment started + try: + await send_deployment_webhook( + project=deployment.project, + deployment=deployment, + event="started", + url_scheme=settings.url_scheme, + deploy_domain=settings.deploy_domain, + db=db, + ) + except Exception as e: + logger.warning(f"{log_prefix} Webhook delivery failed: {e}") + # Prepare environment variables env_vars_dict = { var["key"]: var["value"] for var in (deployment.env_vars or []) @@ -103,7 +117,7 @@ async def deploy_start(ctx, deployment_id: str): "git init -q && " "printf '%s\n' " "'#!/bin/sh' " - "'case \"$1\" in *Username*) echo \"x-access-token\";; *) echo \"$DEVPUSH_GITHUB_TOKEN\";; esac' " + '\'case "$1" in *Username*) echo "x-access-token";; *) echo "$DEVPUSH_GITHUB_TOKEN";; esac\' ' "> /tmp/devpush-git-askpass && " "chmod 700 /tmp/devpush-git-askpass && " "export GIT_ASKPASS=/tmp/devpush-git-askpass GIT_TERMINAL_PROMPT=0 && " @@ -398,6 +412,19 @@ async def deploy_finalize(ctx, deployment_id: str): f"stream:project:{deployment.project_id}:updates", fields ) + # Send webhook notification for deployment succeeded + try: + await send_deployment_webhook( + project=deployment.project, + deployment=deployment, + event="succeeded", + url_scheme=settings.url_scheme, + deploy_domain=settings.deploy_domain, + db=db, + ) + except Exception as e: + logger.warning(f"{log_prefix} Webhook delivery failed: {e}") + except Exception: logger.error(f"{log_prefix} Error finalizing deployment.", exc_info=True) @@ -476,4 +503,18 @@ async def deploy_fail(ctx, deployment_id: str, reason: str = None): await redis_client.xadd( f"stream:project:{deployment.project_id}:updates", fields ) + + # Send webhook notification for deployment failed + try: + await send_deployment_webhook( + project=deployment.project, + deployment=deployment, + event="failed", + url_scheme=settings.url_scheme, + deploy_domain=settings.deploy_domain, + db=db, + ) + except Exception as e: + logger.warning(f"{log_prefix} Webhook delivery failed: {e}") + logger.error(f"{log_prefix} Deployment failed and cleaned up.")