11"""
22Self-register this processing service's pipelines with Antenna.
33
4- Modeled on the PR #1194 version (which targets API-key auth once merged). For
5- now this targets main, which still uses user-token auth and requires
6- `processing_service_name` in the registration body.
7-
8- Auth priority:
9- 1. ANTENNA_API_KEY set → use `Api-Key <key>` (TODO: activate once PR #1194 merges)
10- 2. ANTENNA_API_AUTH_TOKEN set → use `Token <token>`
11- 3. ANTENNA_USER / ANTENNA_PASSWORD → log in, use the returned token.
12-
13- Self-provisioning (option 3) matches the local-dev and CI defaults baked into
14- .envs/.local/.django (antenna@insectai.org / localadmin).
15-
16- Environment:
17- ANTENNA_API_URL Base URL (e.g. http://django:8000)
18- ANTENNA_PROJECT_ID Project PK OR ANTENNA_DEFAULT_PROJECT_NAME to resolve to one.
19- ANTENNA_DEFAULT_PROJECT_NAME Fallback lookup by name (default: "Default Project")
20- ANTENNA_SERVICE_NAME ProcessingService name (default: minimal-worker-<hostname>)
21- ANTENNA_API_KEY Optional (future, PR #1194 path)
22- ANTENNA_API_AUTH_TOKEN Optional static token (skips login)
23- ANTENNA_USER Fallback login email (default: antenna@insectai.org)
24- ANTENNA_PASSWORD Fallback login password (default: localadmin)
4+ What this does, in order:
5+ 1. Resolve an Authorization header (env var or fallback login).
6+ 2. Resolve the target project id (env var, else look up by name).
7+ 3. Fetch our own /info to get the list of pipelines this container serves.
8+ 4. POST that list to `/api/v2/projects/{id}/pipelines/` so Antenna knows which
9+ async pipelines this ProcessingService can handle.
10+
11+ About identity: on main, the server looks up / creates a `ProcessingService`
12+ record by the `processing_service_name` field in the request body, and grants
13+ write access based on the Authorization header's user. PR #1194 changes that
14+ to use API keys — the PS record is derived from the key itself and
15+ `processing_service_name` is no longer sent. We tolerate both by sending
16+ `processing_service_name` now; #1194-enabled Antenna will ignore the field and
17+ pick the PS from the key.
18+
19+ Env vars are read via `os.environ[...]` without fallbacks — the .env file is
20+ expected to provide them. See `processing_services/.env.example`.
2521"""
2622
2723import logging
2824import os
29- import platform
30- import socket
3125import sys
3226import time
3327
3428import requests
29+ from api .api import pipelines as pipeline_classes # type: ignore[import-not-found]
30+ from api .schemas import ( # type: ignore[import-not-found]
31+ AsyncPipelineRegistrationRequest ,
32+ PipelineConfigResponse ,
33+ ProcessingServiceClientInfo ,
34+ )
3535
3636logger = logging .getLogger (__name__ )
3737
3838MAX_RETRIES = 20
3939RETRY_DELAY = 3 # seconds
4040
41- DEFAULT_USER = "antenna@insectai.org"
42- DEFAULT_PASSWORD = "localadmin"
43- DEFAULT_PROJECT_NAME = "Default Project"
44- LOCAL_INFO_URL = "http://localhost:2000/info"
45- LOCAL_LIVEZ_URL = "http://localhost:2000/livez"
41+ CACHED_AUTH_HEADER_PATH = "/tmp/antenna_auth_header"
4642
4743
48- def get_client_info () -> dict :
49- """Identity metadata sent to Antenna.
44+ def get_client_info () -> ProcessingServiceClientInfo :
45+ """Identity metadata sent to Antenna in the registration body .
5046
51- Extra keys are allowed by the ProcessingServiceClientInfo schema (Config. extra = "allow"),
52- so it's fine to add more here; main's registration endpoint currently ignores this field
53- and PR #1194 reads it .
47+ ` ProcessingServiceClientInfo` has ` extra= "allow"`, so any keys here are
48+ forwarded verbatim. On main the registration serializer ignores unknown
49+ fields; PR #1194 consumes this field .
5450 """
55- return {
56- "hostname" : socket .gethostname (),
57- "software" : "antenna-minimal-worker" ,
58- "version" : "0.1.0" ,
59- "platform" : platform .platform (),
60- }
51+ import platform
52+ import socket
53+
54+ return ProcessingServiceClientInfo .model_validate (
55+ {
56+ "hostname" : socket .gethostname (),
57+ "software" : "antenna-minimal-worker" ,
58+ "version" : "0.1.0" ,
59+ "platform" : platform .platform (),
60+ }
61+ )
6162
6263
6364def auth_header () -> dict [str , str ] | None :
6465 """Pick an auth header based on what env vars are set, or None to trigger login flow."""
6566 api_key = os .environ .get ("ANTENNA_API_KEY" )
6667 if api_key :
67- # TODO(PR #1194): Api-Key auth is enabled on Antenna once #1194 merges.
68+ # PR #1194 path. Harmless on main — main ignores unknown auth schemes
69+ # and falls through to the next header, which we don't send.
6870 return {"Authorization" : f"Api-Key { api_key } " }
6971
7072 token = os .environ .get ("ANTENNA_API_AUTH_TOKEN" )
@@ -93,7 +95,7 @@ def resolve_project_id(api_url: str, headers: dict[str, str]) -> str:
9395 if explicit :
9496 return explicit
9597
96- name = os .environ . get ( "ANTENNA_DEFAULT_PROJECT_NAME" , DEFAULT_PROJECT_NAME )
98+ name = os .environ [ "ANTENNA_DEFAULT_PROJECT_NAME" ]
9799 resp = requests .get (f"{ api_url } /api/v2/projects/" , headers = headers , timeout = 10 )
98100 resp .raise_for_status ()
99101 for project in resp .json ().get ("results" , []):
@@ -104,57 +106,54 @@ def resolve_project_id(api_url: str, headers: dict[str, str]) -> str:
104106 raise RuntimeError (f"No project found with name '{ name } ' — ensure_default_project should have created it" )
105107
106108
107- def fetch_own_pipelines () -> list [dict ]:
108- resp = requests .get (LOCAL_INFO_URL , timeout = 5 )
109- resp .raise_for_status ()
110- return resp .json ().get ("pipelines" , [])
111-
109+ def fetch_own_pipelines () -> list [PipelineConfigResponse ]:
110+ """Return the pipeline configs this container serves.
112111
113- def wait_for_local_server () -> None :
114- for attempt in range (MAX_RETRIES ):
115- try :
116- r = requests .get (LOCAL_LIVEZ_URL , timeout = 2 )
117- if r .status_code == 200 :
118- return
119- except (requests .ConnectionError , requests .Timeout ):
120- pass
121- logger .info ("Waiting for local FastAPI server (%d/%d)" , attempt + 1 , MAX_RETRIES )
122- time .sleep (RETRY_DELAY )
123- raise RuntimeError ("Local FastAPI server did not come up in time" )
112+ Imported directly from the api module rather than fetched over HTTP from
113+ the co-located FastAPI service — register.py runs in the same container,
114+ and importing avoids having to wait for FastAPI to be up (which it isn't
115+ in MODE=worker).
116+ """
117+ return [p .config for p in pipeline_classes ]
124118
125119
126- def register (api_url : str , project_id : str , headers : dict [str , str ], pipelines : list [dict ]) -> None :
120+ def register (
121+ api_url : str ,
122+ project_id : str ,
123+ headers : dict [str , str ],
124+ pipelines : list [PipelineConfigResponse ],
125+ ) -> None :
127126 """POST pipelines to the project registration endpoint.
128127
129- Body shape for main:
130- {"processing_service_name": str, "pipelines": [...], "client_info": {...}}
131- PR #1194 drops `processing_service_name`. Sending both now is safe because
132- main's serializer ignores unknown fields and #1194's serializer ignores
133- `processing_service_name`.
128+ Sends the schema-defined `AsyncPipelineRegistrationRequest` body. We also
129+ attach a `client_info` field, which is ignored on main (unknown field) and
130+ read by PR #1194.
134131 """
135- service_name = os .environ .get ("ANTENNA_SERVICE_NAME" , f"minimal-worker-{ socket .gethostname ()} " )
136- payload = {
137- "processing_service_name" : service_name ,
138- "pipelines" : pipelines ,
139- "client_info" : get_client_info (),
140- }
132+ service_name = os .environ ["ANTENNA_SERVICE_NAME" ]
133+ body = AsyncPipelineRegistrationRequest (
134+ processing_service_name = service_name ,
135+ pipelines = pipelines ,
136+ ).model_dump (mode = "json" )
137+ body ["client_info" ] = get_client_info ().model_dump (mode = "json" )
138+
141139 url = f"{ api_url } /api/v2/projects/{ project_id } /pipelines/"
142- resp = requests .post (url , json = payload , headers = headers , timeout = 30 )
140+ resp = requests .post (url , json = body , headers = headers , timeout = 30 )
143141 if resp .status_code in (200 , 201 ):
144- logger .info ("Registered %d pipelines as '%s' (project=%s)" , len (pipelines ), service_name , project_id )
142+ logger .info (
143+ "Registered %d pipelines as '%s' (project=%s)" ,
144+ len (pipelines ),
145+ service_name ,
146+ project_id ,
147+ )
145148 return
146149 raise RuntimeError (f"Registration failed: { resp .status_code } { resp .text } " )
147150
148151
149152def main () -> int :
150153 logging .basicConfig (level = logging .INFO , format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" )
151154
152- api_url = os .environ .get ("ANTENNA_API_URL" )
153- if not api_url :
154- logger .error ("ANTENNA_API_URL not set; skipping registration" )
155- return 0
155+ api_url = os .environ ["ANTENNA_API_URL" ]
156156
157- wait_for_local_server ()
158157 pipelines = fetch_own_pipelines ()
159158 if not pipelines :
160159 logger .warning ("No pipelines found from local /info; nothing to register" )
@@ -163,8 +162,8 @@ def main() -> int:
163162 # Auth: use explicit header if provided, else log in.
164163 headers = auth_header ()
165164 if headers is None :
166- email = os .environ . get ( "ANTENNA_USER" , DEFAULT_USER )
167- password = os .environ . get ( "ANTENNA_PASSWORD" , DEFAULT_PASSWORD )
165+ email = os .environ [ "ANTENNA_USER" ]
166+ password = os .environ [ "ANTENNA_PASSWORD" ]
168167 # Retry login so we tolerate "Django not up yet".
169168 for attempt in range (MAX_RETRIES ):
170169 try :
@@ -193,7 +192,7 @@ def main() -> int:
193192
194193 # Cache the resolved id and auth header for worker_main.py to reuse.
195194 os .environ ["ANTENNA_PROJECT_ID" ] = project_id
196- with open ("/tmp/antenna_auth_header" , "w" ) as f :
195+ with open (CACHED_AUTH_HEADER_PATH , "w" ) as f :
197196 f .write (next (iter (headers .values ())))
198197
199198 for attempt in range (MAX_RETRIES ):
0 commit comments