=== STEP_034C WGPAY TEST PEER RUNBOOK === timestamp=20260708-113652 mode=read-only / package only goal: build exact existing-tooling runbook to create/cancel a dedicated test WGPay peer remote_changes=none === host === wg-access-dev Wed Jul 8 11:36:52 UTC 2026 === backend reachability === NAMES IMAGE STATUS PORTS wgaccess-backend python:3.12-slim Up 41 hours 0.0.0.0:18080->8000/tcp wgaccess-postgres postgres:16-alpine Up 41 hours (healthy) 127.0.0.1:15432->5432/tcp URL http://127.0.0.1:18080/health curl_rc=0 {"status":"ok","service":"wg-access-backend","environment":"dev"} URL http://127.0.0.1:18080/health/db curl_rc=0 {"status":"ok","db":1} URL http://127.0.0.1:18080/openapi.json curl_rc=0 {"openapi":"3.1.0","info":{"title":"WG Access Backend","version":"0.0.3"},"paths":{"/admin/invites":{"get":{"tags":["admin"],"summary":"List Invites","operationId":"list_invites_admin_invites_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/InviteResponse"},"type":"array","title":"Response List Invites Admin Invites Get"}}}}}},"post":{"tags":["admin"],"summary":"Create Invite","operationId":"create_invite_admin_invites_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/peers":{"get":{"tags":["admin"],"summary":"List Peers","operationId":"list_peers_admin_peers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PeerResponse"},"type":"array" === OpenAPI relevant paths === /admin/invites ['get', 'post'] get summary= List Invites post summary= Create Invite post requestBody= dict_keys(['application/json']) /admin/jobs ['get'] get summary= List Jobs /admin/maintenance/expire-subscriptions ['post'] post summary= Expire Subscriptions /admin/peers ['get'] get summary= List Peers /admin/peers/{peer_id}/disable ['post'] post summary= Disable Peer /admin/subscriptions ['post'] post summary= Create Subscription post requestBody= dict_keys(['application/json']) /admin/subscriptions/{subscription_id}/cancel ['post'] post summary= Cancel Subscription /admin/users ['get'] get summary= List Users /admin/users/by-email/{email} ['get'] get summary= Get User By Email /admin/users/{user_id} ['get'] get summary= Get User /agent/jobs ['get'] get summary= Get Pending Jobs /agent/jobs/{job_id}/complete ['post'] post summary= Complete Job /agent/jobs/{job_id}/fail ['post'] post summary= Fail Job post requestBody= dict_keys(['application/json']) /agent/jobs/{job_id}/start ['post'] post summary= Start Job /agent/peers ['get'] get summary= Get Enabled Peers /dev/create-test-peer ['post'] post summary= Create Test Peer post requestBody= dict_keys(['application/json']) === admin.py subscription/cancel/disable excerpts === --- /opt/wg-access/backend/app/api/admin.py:80-180 --- 0080: return rows 0081: 0082: 0083: 0084: class SubscriptionCreateRequest(BaseModel): 0085: email: EmailStr | None = None 0086: months: int = Field(default=1, ge=1, le=36) 0087: plan_code: str = "manual" 0088: node_id: str = "ddn-test" 0089: auto_renew: bool = False 0090: 0091: 0092: class SubscriptionCreateResponse(BaseModel): 0093: user_id: UUID 0094: subscription_id: UUID 0095: peer_id: UUID 0096: job_id: UUID 0097: node_id: str 0098: tunnel_ip: str 0099: paid_until: datetime 0100: private_key: str 0101: public_key: str 0102: preshared_key: str 0103: client_config: str 0104: 0105: 0106: @router.post("/subscriptions", response_model=SubscriptionCreateResponse) 0107: def create_subscription(payload: SubscriptionCreateRequest, db: Session = Depends(get_db)): 0108: # MVP admin/manual subscription endpoint. 0109: # It creates a user, an active subscription, a WG peer and a pending enable_peer job. 0110: # The private key is intentionally returned once and not stored in DB. 0111: paid_until = datetime.now(timezone.utc) + timedelta(days=30 * payload.months) 0112: 0113: user = User(email=payload.email) 0114: db.add(user) 0115: db.flush() 0116: 0117: subscription = Subscription( 0118: user_id=user.id, 0119: status="active", 0120: plan_code=payload.plan_code, 0121: auto_renew=payload.auto_renew, 0122: paid_until=paid_until, 0123: ) 0124: db.add(subscription) 0125: db.flush() 0126: 0127: tunnel_ip = next_tunnel_ip(db, payload.node_id) 0128: private_key, public_key = generate_wg_keypair() 0129: preshared_key = generate_wg_psk() 0130: client_config = build_client_config(private_key, tunnel_ip, preshared_key) 0131: 0132: peer = Peer( 0133: user_id=user.id, 0134: node_id=payload.node_id, 0135: public_key=public_key, 0136: preshared_key=preshared_key, 0137: tunnel_ip=tunnel_ip, 0138: paid_until=paid_until, 0139: enabled=True, 0140: ) 0141: db.add(peer) 0142: db.flush() 0143: 0144: job = ProvisioningJob( 0145: node_id=payload.node_id, 0146: action="enable_peer", 0147: peer_id=peer.id, 0148: payload_json={ 0149: "public_key": peer.public_key, 0150: "preshared_key": peer.preshared_key, 0151: "tunnel_ip": peer.tunnel_ip, 0152: "paid_until": peer.paid_until.isoformat(), 0153: }, 0154: status="pending", 0155: attempts=0, 0156: ) 0157: db.add(job) 0158: 0159: db.commit() 0160: db.refresh(subscription) 0161: db.refresh(peer) 0162: db.refresh(job) 0163: 0164: return SubscriptionCreateResponse( 0165: user_id=user.id, 0166: subscription_id=subscription.id, 0167: peer_id=peer.id, 0168: job_id=job.id, 0169: node_id=peer.node_id, 0170: tunnel_ip=peer.tunnel_ip, 0171: paid_until=peer.paid_until, 0172: private_key=private_key, 0173: public_key=peer.public_key, 0174: preshared_key=peer.preshared_key, 0175: client_config=client_config, 0176: ) 0177: 0178: 0179: 0180: --- /opt/wg-access/backend/app/api/admin.py:240-355 --- 0240: job_ids=[job.id for job in jobs], 0241: subscription_ids=[subscription.id for subscription in expired_subscriptions], 0242: ) 0243: 0244: 0245: class CancelSubscriptionResponse(BaseModel): 0246: subscription_id: UUID 0247: user_id: UUID 0248: status: str 0249: canceled_at: datetime | None 0250: disabled_peer_count: int 0251: job_ids: list[UUID] 0252: 0253: 0254: @router.post("/subscriptions/{subscription_id}/cancel", response_model=CancelSubscriptionResponse) 0255: def cancel_subscription(subscription_id: UUID, db: Session = Depends(get_db)): 0256: subscription = db.get(Subscription, subscription_id) 0257: if not subscription: 0258: raise HTTPException(status_code=404, detail="subscription not found") 0259: 0260: now = datetime.now(timezone.utc) 0261: 0262: # Idempotent business-level cancel: 0263: # cancel subscription once, then disable any currently enabled peers for this user. 0264: subscription.status = "canceled" 0265: subscription.auto_renew = False 0266: if subscription.canceled_at is None: 0267: subscription.canceled_at = now 0268: 0269: enabled_peers = db.execute( 0270: select(Peer) 0271: .where(Peer.user_id == subscription.user_id) 0272: .where(Peer.enabled == True) # noqa: E712 0273: .order_by(Peer.created_at.desc()) 0274: ).scalars().all() 0275: 0276: jobs = [] 0277: for peer in enabled_peers: 0278: peer.enabled = False 0279: peer.disabled_at = now 0280: 0281: job = ProvisioningJob( 0282: node_id=peer.node_id, 0283: action="disable_peer", 0284: peer_id=peer.id, 0285: payload_json={ 0286: "public_key": peer.public_key, 0287: "tunnel_ip": peer.tunnel_ip, 0288: }, 0289: status="pending", 0290: attempts=0, 0291: ) 0292: db.add(job) 0293: jobs.append(job) 0294: 0295: db.commit() 0296: db.refresh(subscription) 0297: for job in jobs: 0298: db.refresh(job) 0299: 0300: return CancelSubscriptionResponse( 0301: subscription_id=subscription.id, 0302: user_id=subscription.user_id, 0303: status=subscription.status, 0304: canceled_at=subscription.canceled_at, 0305: disabled_peer_count=len(enabled_peers), 0306: job_ids=[job.id for job in jobs], 0307: ) 0308: 0309: 0310: class DisablePeerResponse(BaseModel): 0311: peer_id: UUID 0312: job_id: UUID 0313: node_id: str 0314: public_key: str 0315: tunnel_ip: str 0316: enabled: bool 0317: 0318: 0319: @router.post("/peers/{peer_id}/disable", response_model=DisablePeerResponse) 0320: def disable_peer(peer_id: UUID, db: Session = Depends(get_db)): 0321: peer = db.get(Peer, peer_id) 0322: if not peer: 0323: from fastapi import HTTPException 0324: raise HTTPException(status_code=404, detail="peer not found") 0325: 0326: peer.enabled = False 0327: peer.disabled_at = datetime.now(timezone.utc) 0328: 0329: job = ProvisioningJob( 0330: node_id=peer.node_id, 0331: action="disable_peer", 0332: peer_id=peer.id, 0333: payload_json={ 0334: "public_key": peer.public_key, 0335: "tunnel_ip": peer.tunnel_ip, 0336: }, 0337: status="pending", 0338: attempts=0, 0339: ) 0340: db.add(job) 0341: db.commit() 0342: db.refresh(peer) 0343: db.refresh(job) 0344: 0345: return DisablePeerResponse( 0346: peer_id=peer.id, 0347: job_id=job.id, 0348: node_id=peer.node_id, 0349: public_key=peer.public_key, 0350: tunnel_ip=peer.tunnel_ip, 0351: enabled=peer.enabled, 0352: ) 0353: 0354: 0355: --- /opt/wg-access/backend/app/api/admin.py:360-420 --- 0360: status: str 0361: attempts: int 0362: created_at: datetime 0363: last_error: str | None 0364: 0365: class Config: 0366: from_attributes = True 0367: 0368: 0369: @router.get("/jobs", response_model=list[JobResponse]) 0370: def list_jobs(db: Session = Depends(get_db)): 0371: rows = db.execute(select(ProvisioningJob).order_by(ProvisioningJob.created_at.desc())).scalars().all() 0372: return rows 0373: 0374: 0375: class AdminSubscriptionResponse(BaseModel): 0376: id: UUID 0377: user_id: UUID 0378: status: str 0379: plan_code: str 0380: auto_renew: bool 0381: paid_until: datetime 0382: created_at: datetime 0383: canceled_at: datetime | None 0384: 0385: class Config: 0386: from_attributes = True 0387: 0388: 0389: class AdminUserPeerResponse(BaseModel): 0390: id: UUID 0391: user_id: UUID 0392: node_id: str 0393: public_key: str 0394: tunnel_ip: str 0395: paid_until: datetime 0396: enabled: bool 0397: created_at: datetime 0398: disabled_at: datetime | None 0399: 0400: class Config: 0401: from_attributes = True 0402: 0403: 0404: class AdminUserJobResponse(BaseModel): 0405: id: UUID 0406: node_id: str 0407: action: str 0408: peer_id: UUID | None 0409: status: str 0410: attempts: int 0411: created_at: datetime 0412: last_error: str | None 0413: 0414: class Config: 0415: from_attributes = True 0416: 0417: 0418: class AdminUserSummaryResponse(BaseModel): 0419: id: UUID 0420: email: str | None === wireguard.py allocation/config excerpts === File "", line 9 echo "=== latest MGTS smoke artifacts summary ===" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ SyntaxError: invalid syntax bash: line 104: syntax error near unexpected token `fi' bash: line 104: ` fi' === STEP_034C RESULT === remote_rc=2 subscription_endpoint_lines=4 cancel_endpoint_lines=3 disable_endpoint_lines=5 openapi_relevant_lines=6 smoke_tunnel_related_lines=11 api_peers_ok_lines=0 api_jobs_ok_lines=0 package=/opt/router-ops/apply-packages/20260708-113652_step034c_wgpay_test_peer_runbook decision=BLOCK_REMOTE_RC