1- //! Toolchains API: a read-only catalog of the language-toolchain images
2- //! overup understands (`services::toolchain_images`), tagged with the
3- //! deployment's default image, prewarm state, and whether an image allow-list
4- //! is active. Rides `content.read` like the other workspace read surfaces.
5- //! Static strings only — no mutation surface, no user/runner text.
1+ //! Toolchains API: a catalog of the language-toolchain images overup
2+ //! understands (`services::toolchain_images`), tagged with the deployment's
3+ //! default image, prewarm/install state, and whether an image allow-list is
4+ //! active. The catalog read rides `content.read`; install/uninstall (an
5+ //! on-demand `docker pull`/`rmi` on the hosted-runner daemon) ride
6+ //! `content.write`, matching hosted-runner management. Image strings are always
7+ //! the static catalog values — no user text ever reaches Docker.
8+
9+ use std:: time:: Duration ;
610
711use axum:: Json ;
812use axum:: extract:: { Path , State } ;
13+ use axum:: http:: { HeaderMap , StatusCode } ;
14+ use serde_json:: json;
915use uuid:: Uuid ;
1016
11- use crate :: error:: AppResult ;
17+ use crate :: db;
18+ use crate :: error:: { AppError , AppResult } ;
1219use crate :: middleware:: auth:: CurrentUser ;
1320use crate :: models:: toolchain:: ToolchainsResponse ;
1421use crate :: services:: authz;
22+ use crate :: services:: toolchain_images;
1523use crate :: state:: AppState ;
1624
25+ /// Cap for an on-demand toolchain pull — matches the hosted-create budget.
26+ const PULL_TIMEOUT : Duration = Duration :: from_secs ( 600 ) ;
27+
1728/// GET /api/workspaces/{workspace_id}/toolchains
1829pub async fn list (
1930 State ( state) : State < AppState > ,
@@ -31,9 +42,180 @@ pub async fn list(
3142 . map ( |p| p. prepull_images . as_slice ( ) )
3243 . unwrap_or ( & [ ] ) ;
3344
45+ let install_supported = match & state. runner_provisioner {
46+ Some ( provisioner) => provisioner. available ( ) . await ,
47+ None => false ,
48+ } ;
49+ let installed = db:: toolchain_images:: list ( & state. pool ) . await ?;
50+
3451 Ok ( Json ( ToolchainsResponse :: build (
3552 state. config . default_job_image . clone ( ) ,
3653 prepull,
3754 !state. config . image_allowlist . is_empty ( ) ,
55+ install_supported,
56+ & installed,
3857 ) ) )
3958}
59+
60+ /// POST /api/workspaces/{workspace_id}/toolchains/{key}/install
61+ pub async fn install (
62+ State ( state) : State < AppState > ,
63+ CurrentUser ( user) : CurrentUser ,
64+ Path ( ( workspace_id, key) ) : Path < ( Uuid , String ) > ,
65+ headers : HeaderMap ,
66+ ) -> AppResult < StatusCode > {
67+ authz:: require_permission ( & state. pool , user. id , workspace_id, authz:: CONTENT_WRITE ) . await ?;
68+
69+ // Only known catalog keys install, and only their static -latest image.
70+ let toolchain = toolchain_images:: all ( )
71+ . iter ( )
72+ . find ( |t| t. key == key)
73+ . ok_or ( AppError :: NotFound ) ?;
74+ let image = toolchain. image_latest ;
75+
76+ // Pulls need a live provisioner daemon. Static category the UI maps to copy.
77+ let Some ( provisioner) = state. runner_provisioner . clone ( ) else {
78+ return Err ( AppError :: Conflict ( "hosted_runner_unavailable" ) ) ;
79+ } ;
80+ if !provisioner. available ( ) . await {
81+ return Err ( AppError :: Conflict ( "hosted_runner_unavailable" ) ) ;
82+ }
83+
84+ let request_id = headers. get ( "x-request-id" ) . and_then ( |v| v. to_str ( ) . ok ( ) ) ;
85+ db:: toolchain_images:: upsert_pending ( & state. pool , toolchain. key , image) . await ?;
86+ record_audit (
87+ & state,
88+ workspace_id,
89+ user. id ,
90+ "toolchain.install_requested" ,
91+ toolchain. key ,
92+ image,
93+ request_id,
94+ )
95+ . await ?;
96+
97+ // The pull can take minutes — run it off the request path and let the row
98+ // status (observed via the catalog GET) drive the UI.
99+ let pool = state. pool . clone ( ) ;
100+ let tc_key = toolchain. key . to_string ( ) ;
101+ let img = image. to_string ( ) ;
102+ tokio:: spawn ( async move {
103+ let outcome = tokio:: time:: timeout ( PULL_TIMEOUT , provisioner. pull ( & img) ) . await ;
104+ match outcome {
105+ Ok ( Ok ( ( ) ) ) => {
106+ let _ = db:: toolchain_images:: mark_installed ( & pool, & tc_key) . await ;
107+ let _ = insert_audit ( & pool, workspace_id, Some ( user. id ) , "toolchain.installed" , & tc_key, & img) . await ;
108+ }
109+ Ok ( Err ( err) ) => {
110+ tracing:: warn!( key = %tc_key, error = ?err, "toolchain install pull failed" ) ;
111+ let _ = db:: toolchain_images:: mark_failed ( & pool, & tc_key, "image_pull_failed" ) . await ;
112+ let _ = insert_audit ( & pool, workspace_id, Some ( user. id ) , "toolchain.install_failed" , & tc_key, & img) . await ;
113+ }
114+ Err ( _) => {
115+ tracing:: warn!( key = %tc_key, "toolchain install pull timed out" ) ;
116+ let _ = db:: toolchain_images:: mark_failed ( & pool, & tc_key, "pull_timeout" ) . await ;
117+ let _ = insert_audit ( & pool, workspace_id, Some ( user. id ) , "toolchain.install_failed" , & tc_key, & img) . await ;
118+ }
119+ }
120+ } ) ;
121+
122+ Ok ( StatusCode :: ACCEPTED )
123+ }
124+
125+ /// DELETE /api/workspaces/{workspace_id}/toolchains/{key}
126+ pub async fn uninstall (
127+ State ( state) : State < AppState > ,
128+ CurrentUser ( user) : CurrentUser ,
129+ Path ( ( workspace_id, key) ) : Path < ( Uuid , String ) > ,
130+ headers : HeaderMap ,
131+ ) -> AppResult < StatusCode > {
132+ authz:: require_permission ( & state. pool , user. id , workspace_id, authz:: CONTENT_WRITE ) . await ?;
133+
134+ let toolchain = toolchain_images:: all ( )
135+ . iter ( )
136+ . find ( |t| t. key == key)
137+ . ok_or ( AppError :: NotFound ) ?;
138+
139+ // Never remove the image jobs fall back to — that would break every job
140+ // that declares no container.
141+ if toolchain. image_latest == state. config . default_job_image {
142+ return Err ( AppError :: Conflict ( "toolchain_in_use" ) ) ;
143+ }
144+
145+ let request_id = headers. get ( "x-request-id" ) . and_then ( |v| v. to_str ( ) . ok ( ) ) ;
146+ // Dropping the row stops prewarming immediately; the rmi is best-effort.
147+ let removed = db:: toolchain_images:: delete ( & state. pool , toolchain. key ) . await ?;
148+ if removed. is_none ( ) {
149+ return Err ( AppError :: NotFound ) ;
150+ }
151+ record_audit (
152+ & state,
153+ workspace_id,
154+ user. id ,
155+ "toolchain.uninstalled" ,
156+ toolchain. key ,
157+ toolchain. image_latest ,
158+ request_id,
159+ )
160+ . await ?;
161+
162+ if let ( Some ( provisioner) , Some ( image) ) = ( state. runner_provisioner . clone ( ) , removed) {
163+ tokio:: spawn ( async move {
164+ let _ = provisioner. remove_image ( & image) . await ;
165+ } ) ;
166+ }
167+
168+ Ok ( StatusCode :: NO_CONTENT )
169+ }
170+
171+ /// Audit helper carrying the request id (for the synchronous request path).
172+ async fn record_audit (
173+ state : & AppState ,
174+ workspace_id : Uuid ,
175+ actor : Uuid ,
176+ action : & str ,
177+ key : & str ,
178+ image : & str ,
179+ request_id : Option < & str > ,
180+ ) -> AppResult < ( ) > {
181+ sqlx:: query (
182+ r#"
183+ INSERT INTO audit_logs
184+ (workspace_id, actor_user_id, action, subject_type, subject_id, metadata, request_id)
185+ VALUES ($1, $2, $3, 'toolchain', NULL, $4, $5)
186+ "# ,
187+ )
188+ . bind ( workspace_id)
189+ . bind ( actor)
190+ . bind ( action)
191+ . bind ( json ! ( { "key" : key, "image" : image } ) )
192+ . bind ( request_id)
193+ . execute ( & state. pool )
194+ . await ?;
195+ Ok ( ( ) )
196+ }
197+
198+ /// Audit helper for the background pull result (no request id available).
199+ async fn insert_audit (
200+ pool : & sqlx:: PgPool ,
201+ workspace_id : Uuid ,
202+ actor : Option < Uuid > ,
203+ action : & str ,
204+ key : & str ,
205+ image : & str ,
206+ ) -> sqlx:: Result < ( ) > {
207+ sqlx:: query (
208+ r#"
209+ INSERT INTO audit_logs
210+ (workspace_id, actor_user_id, action, subject_type, subject_id, metadata)
211+ VALUES ($1, $2, $3, 'toolchain', NULL, $4)
212+ "# ,
213+ )
214+ . bind ( workspace_id)
215+ . bind ( actor)
216+ . bind ( action)
217+ . bind ( json ! ( { "key" : key, "image" : image } ) )
218+ . execute ( pool)
219+ . await ?;
220+ Ok ( ( ) )
221+ }
0 commit comments