Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:32:10.125Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

features.rs

350 lines · 11.4 KB · rust
1use std::{collections::HashMap, path::PathBuf, sync::Arc};
2
3use fs::Fs;
4use serde::Deserialize;
5use serde_json_lenient::Value;
6
7use crate::{
8    devcontainer_api::DevContainerError,
9    devcontainer_json::{FeatureOptions, MountDefinition},
10    safe_id_upper,
11};
12
13/// Parsed components of an OCI feature reference such as
14/// `ghcr.io/devcontainers/features/aws-cli:1`.
15///
16/// Mirrors the CLI's `OCIRef` in `containerCollectionsOCI.ts`.
17#[derive(Debug, Clone)]
18pub(crate) struct OciFeatureRef {
19    /// Registry hostname, e.g. `ghcr.io`
20    pub registry: String,
21    /// Full repository path within the registry, e.g. `devcontainers/features/aws-cli`
22    pub path: String,
23    /// Version tag, digest, or `latest`
24    pub version: String,
25}
26
27/// Minimal representation of a `devcontainer-feature.json` file, used to
28/// extract option default values after the feature tarball is downloaded.
29///
30/// See: https://containers.dev/implementors/features/#devcontainer-featurejson-properties
31#[derive(Debug, Deserialize, Eq, PartialEq, Default)]
32#[serde(rename_all = "camelCase")]
33pub(crate) struct DevContainerFeatureJson {
34    #[serde(rename = "id")]
35    pub(crate) _id: Option<String>,
36    #[serde(default)]
37    pub(crate) options: HashMap<String, FeatureOptionDefinition>,
38    pub(crate) mounts: Option<Vec<MountDefinition>>,
39    pub(crate) init: Option<bool>,
40    pub(crate) privileged: Option<bool>,
41    pub(crate) entrypoint: Option<String>,
42    pub(crate) container_env: Option<HashMap<String, String>>,
43    pub(crate) cap_add: Option<Vec<String>>,
44    pub(crate) security_opt: Option<Vec<String>>,
45    pub(crate) customizations: Option<Value>,
46    pub(crate) on_create_command: Option<Value>,
47    pub(crate) update_content_command: Option<Value>,
48    pub(crate) post_create_command: Option<Value>,
49    pub(crate) post_start_command: Option<Value>,
50    pub(crate) post_attach_command: Option<Value>,
51}
52
53/// A single option definition inside `devcontainer-feature.json`.
54/// We only need the `default` field to populate env variables.
55#[derive(Debug, Deserialize, Eq, PartialEq)]
56pub(crate) struct FeatureOptionDefinition {
57    pub(crate) default: Option<Value>,
58}
59
60impl FeatureOptionDefinition {
61    fn serialize_default(&self) -> Option<String> {
62        self.default.as_ref().map(|some_value| match some_value {
63            Value::Bool(b) => b.to_string(),
64            Value::String(s) => s.to_string(),
65            Value::Number(n) => n.to_string(),
66            other => other.to_string(),
67        })
68    }
69}
70
71#[derive(Debug, Eq, PartialEq, Default)]
72pub(crate) struct FeatureManifest {
73    consecutive_id: String,
74    user_feature_id: String,
75    file_path: PathBuf,
76    feature_json: DevContainerFeatureJson,
77}
78
79impl FeatureManifest {
80    pub(crate) fn new(
81        consecutive_id: String,
82        user_feature_id: String,
83        file_path: PathBuf,
84        feature_json: DevContainerFeatureJson,
85    ) -> Self {
86        Self {
87            consecutive_id,
88            user_feature_id,
89            file_path,
90            feature_json,
91        }
92    }
93    pub(crate) fn container_env(&self) -> HashMap<String, String> {
94        self.feature_json.container_env.clone().unwrap_or_default()
95    }
96
97    pub(crate) fn generate_dockerfile_feature_layer(
98        &self,
99        use_buildkit: bool,
100        dest: &str,
101    ) -> String {
102        let id = &self.consecutive_id;
103        if use_buildkit {
104            format!(
105                r#"
106RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./{id},target=/tmp/build-features-src/{id} \
107cp -ar /tmp/build-features-src/{id} {dest} \
108&& chmod -R 0755 {dest}/{id} \
109&& cd {dest}/{id} \
110&& chmod +x ./devcontainer-features-install.sh \
111&& ./devcontainer-features-install.sh \
112&& rm -rf {dest}/{id}
113"#,
114            )
115        } else {
116            let source = format!("/tmp/build-features/{id}");
117            let full_dest = format!("{dest}/{id}");
118            format!(
119                r#"
120COPY --chown=root:root --from=dev_containers_feature_content_source {source} {full_dest}
121RUN chmod -R 0755 {full_dest} \
122&& cd {full_dest} \
123&& chmod +x ./devcontainer-features-install.sh \
124&& ./devcontainer-features-install.sh
125"#
126            )
127        }
128    }
129
130    pub(crate) fn generate_dockerfile_env(&self) -> String {
131        let mut layer = "".to_string();
132        let env = self.container_env();
133        let mut env: Vec<(&String, &String)> = env.iter().collect();
134        env.sort();
135
136        for (key, value) in env {
137            layer = format!("{layer}ENV {key}={value}\n")
138        }
139        layer
140    }
141
142    /// Merges user options from devcontainer.json with default options defined in this feature manifest
143    pub(crate) fn generate_merged_env(&self, options: &FeatureOptions) -> HashMap<String, String> {
144        let mut merged: HashMap<String, String> = self
145            .feature_json
146            .options
147            .iter()
148            .filter_map(|(k, v)| {
149                v.serialize_default()
150                    .map(|v_some| (safe_id_upper(k), v_some))
151            })
152            .collect();
153
154        match options {
155            FeatureOptions::Bool(_) => {}
156            FeatureOptions::String(version) => {
157                merged.insert("VERSION".to_string(), version.clone());
158            }
159            FeatureOptions::Options(map) => {
160                for (key, value) in map {
161                    merged.insert(safe_id_upper(key), value.to_string());
162                }
163            }
164        }
165        merged
166    }
167
168    pub(crate) async fn write_feature_env(
169        &self,
170        fs: &Arc<dyn Fs>,
171        options: &FeatureOptions,
172    ) -> Result<String, DevContainerError> {
173        let merged_env = self.generate_merged_env(options);
174
175        let mut env_vars: Vec<(&String, &String)> = merged_env.iter().collect();
176        env_vars.sort();
177
178        let env_file_content = env_vars
179            .iter()
180            .fold("".to_string(), |acc, (k, v)| format!("{acc}{}={}\n", k, v));
181
182        fs.write(
183            &self.file_path.join("devcontainer-features.env"),
184            env_file_content.as_bytes(),
185        )
186        .await
187        .map_err(|e| {
188            log::error!("error writing devcontainer feature environment: {e}");
189            DevContainerError::FilesystemError
190        })?;
191
192        Ok(env_file_content)
193    }
194
195    pub(crate) fn mounts(&self) -> Vec<MountDefinition> {
196        if let Some(mounts) = &self.feature_json.mounts {
197            mounts.clone()
198        } else {
199            vec![]
200        }
201    }
202
203    pub(crate) fn init(&self) -> bool {
204        self.feature_json.init.unwrap_or(false)
205    }
206
207    pub(crate) fn privileged(&self) -> bool {
208        self.feature_json.privileged.unwrap_or(false)
209    }
210
211    pub(crate) fn entrypoint(&self) -> Option<String> {
212        self.feature_json.entrypoint.clone()
213    }
214
215    pub(crate) fn cap_add(&self) -> Vec<String> {
216        self.feature_json.cap_add.clone().unwrap_or_default()
217    }
218
219    pub(crate) fn security_opt(&self) -> Vec<String> {
220        self.feature_json.security_opt.clone().unwrap_or_default()
221    }
222
223    pub(crate) fn file_path(&self) -> PathBuf {
224        self.file_path.clone()
225    }
226
227    pub(crate) fn build_metadata_entry(
228        &self,
229    ) -> serde_json_lenient::Map<String, serde_json_lenient::Value> {
230        use serde_json_lenient::Value;
231        let mut entry = serde_json_lenient::Map::new();
232        entry.insert(
233            "id".to_string(),
234            Value::String(self.user_feature_id.clone()),
235        );
236        if let Some(true) = self.feature_json.init {
237            entry.insert("init".to_string(), Value::Bool(true));
238        }
239        if let Some(true) = self.feature_json.privileged {
240            entry.insert("privileged".to_string(), Value::Bool(true));
241        }
242        if let Some(caps) = &self.feature_json.cap_add {
243            if !caps.is_empty() {
244                entry.insert(
245                    "capAdd".to_string(),
246                    Value::Array(caps.iter().map(|s| Value::String(s.clone())).collect()),
247                );
248            }
249        }
250        if let Some(opts) = &self.feature_json.security_opt {
251            if !opts.is_empty() {
252                entry.insert(
253                    "securityOpt".to_string(),
254                    Value::Array(opts.iter().map(|s| Value::String(s.clone())).collect()),
255                );
256            }
257        }
258        if let Some(ep) = &self.feature_json.entrypoint {
259            entry.insert("entrypoint".to_string(), Value::String(ep.clone()));
260        }
261        if let Some(mounts) = &self.feature_json.mounts {
262            if !mounts.is_empty() {
263                entry.insert(
264                    "mounts".to_string(),
265                    Value::Array(
266                        mounts
267                            .iter()
268                            .filter_map(|mount| serde_json_lenient::to_value(mount).ok())
269                            .collect(),
270                    ),
271                );
272            }
273        }
274        if let Some(customizations) = &self.feature_json.customizations {
275            if !customizations.is_null() {
276                entry.insert("customizations".to_string(), customizations.clone());
277            }
278        }
279        for (key, value) in [
280            ("onCreateCommand", &self.feature_json.on_create_command),
281            (
282                "updateContentCommand",
283                &self.feature_json.update_content_command,
284            ),
285            ("postCreateCommand", &self.feature_json.post_create_command),
286            ("postStartCommand", &self.feature_json.post_start_command),
287            ("postAttachCommand", &self.feature_json.post_attach_command),
288        ] {
289            if let Some(value) = value {
290                if !value.is_null() {
291                    entry.insert(key.to_string(), value.clone());
292                }
293            }
294        }
295        entry
296    }
297}
298
299/// Parses an OCI feature reference string into its components.
300///
301/// Handles formats like:
302/// - `ghcr.io/devcontainers/features/aws-cli:1`
303/// - `ghcr.io/user/repo/go`  (implicitly `:latest`)
304/// - `ghcr.io/devcontainers/features/rust@sha256:abc123`
305///
306/// Returns `None` for local paths (`./…`) and direct tarball URIs (`https://…`).
307pub(crate) fn parse_oci_feature_ref(input: &str) -> Option<OciFeatureRef> {
308    if input.starts_with('.')
309        || input.starts_with('/')
310        || input.starts_with("https://")
311        || input.starts_with("http://")
312    {
313        return None;
314    }
315
316    let input_lower = input.to_lowercase();
317
318    let (resource, version) = if let Some(at_idx) = input_lower.rfind('@') {
319        // Digest-based: ghcr.io/foo/bar@sha256:abc
320        (
321            input_lower[..at_idx].to_string(),
322            input_lower[at_idx + 1..].to_string(),
323        )
324    } else {
325        let last_slash = input_lower.rfind('/');
326        let last_colon = input_lower.rfind(':');
327        match (last_slash, last_colon) {
328            (Some(slash), Some(colon)) if colon > slash => (
329                input_lower[..colon].to_string(),
330                input_lower[colon + 1..].to_string(),
331            ),
332            _ => (input_lower, "latest".to_string()),
333        }
334    };
335
336    let parts: Vec<&str> = resource.split('/').collect();
337    if parts.len() < 3 {
338        return None;
339    }
340
341    let registry = parts[0].to_string();
342    let path = parts[1..].join("/");
343
344    Some(OciFeatureRef {
345        registry,
346        path,
347        version,
348    })
349}
350
Served at tenant.openagents/omega Member data and write actions are omitted.