Skip to content

Commit 8f84cf7

Browse files
feloyclaude
andauthored
feat(feature): add dev container features support (#33)
Implements the full features pipeline: OCI registry download with SHA-256 verification and bearer token auth, local feature staging, topological sort on installsAfter, COPY-based build context into a named temp dir, and option key normalization to uppercase env vars. Adds integration tests for common-utils, node, and python OCI features across ubuntu and fedora base images, plus a local feature test exercising option passing and multi-file feature scripts. Signed-off-by: Philippe Martin <phmartin@redhat.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6256972 commit 8f84cf7

12 files changed

Lines changed: 2843 additions & 41 deletions

File tree

Cargo.lock

Lines changed: 822 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ edition = "2024"
2323
clap = { version = "4", features = ["derive", "env"] }
2424
dirs = "5"
2525
env_logger = "0.11"
26+
flate2 = "1"
27+
kdn-workspace-configuration = { git = "https://github.com/openkaiden/kdn-api", tag = "v0.14.0" }
2628
log = "0.4"
29+
oci-spec = { version = "0.7", features = ["image"] }
2730
serde = { version = "1", features = ["derive"] }
31+
serde_json = "1"
32+
sha2 = "0.10"
33+
tar = "0.4"
2834
tempfile = "3"
2935
toml = "0.8"
36+
ureq = "2"
3037

3138
[dev-dependencies]
3239
ctor = "0.2"

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,68 @@ Install the Claude agent in the image:
7676
```sh
7777
openshell-image-builder --agent claude myimage:latest
7878
```
79+
80+
## Dev Container Features
81+
82+
The tool supports [Dev Container Features](https://containers.dev/implementors/features/) declared in `.kaiden/workspace.json` in the current directory.
83+
84+
### workspace.json schema
85+
86+
```json
87+
{
88+
"features": {
89+
"<feature-ref>": {
90+
"<option>": "<value>"
91+
}
92+
}
93+
}
94+
```
95+
96+
Each key in `features` is a feature reference; each value is a map of options passed to the feature's `install.sh`.
97+
98+
### Feature references
99+
100+
| Format | Example | Resolves to |
101+
| --- | --- | --- |
102+
| OCI registry reference | `ghcr.io/devcontainers/features/rust:1` | downloaded from registry |
103+
| Local path | `./my-feature` | `.kaiden/my-feature/` |
104+
105+
Local paths are resolved relative to `.kaiden/`: `./my-feature` points to `.kaiden/my-feature/`.
106+
107+
OCI references without an explicit registry default to `ghcr.io`. Tags and digests (`@sha256:…`) are both supported. Direct `http://` / `https://` tarball URLs are not supported.
108+
109+
### Installation order
110+
111+
Features are installed in the order defined by each feature's `installsAfter` field in its `devcontainer-feature.json`. Within the same dependency level, features are processed in alphabetical order by reference.
112+
113+
### Example
114+
115+
```json
116+
{
117+
"features": {
118+
"ghcr.io/devcontainers/features/rust:1": {
119+
"version": "stable",
120+
"profile": "minimal"
121+
},
122+
"./my-feature": {}
123+
}
124+
}
125+
```
126+
127+
With the above, `./my-feature` refers to a local feature at `.kaiden/my-feature/`.
128+
129+
### How it works
130+
131+
When `.kaiden/workspace.json` is present, the tool:
132+
133+
1. Downloads and extracts each OCI feature into a temporary build context directory (`/tmp/openshell-image-builder…`).
134+
2. Copies local feature directories into the same build context.
135+
3. Passes the build context to `podman build`, where each feature is installed via:
136+
```dockerfile
137+
COPY features/<dir>/ /tmp/feature-install/<dir>/
138+
RUN chmod +x /tmp/feature-install/<dir>/install.sh && \
139+
OPTION="value" /tmp/feature-install/<dir>/install.sh
140+
```
141+
4. Cleans up all feature files from the image with `RUN rm -rf /tmp/feature-install` after all features are installed.
142+
143+
Features run as root so install scripts can write to system paths.

src/build.rs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// SPDX-License-Identifier: Apache-2.0
1616

1717
use std::io::Write;
18+
use std::path::Path;
1819
use std::process::{Command, ExitStatus};
1920

2021
use tempfile::NamedTempFile;
@@ -66,20 +67,26 @@ impl From<std::io::Error> for BuildError {
6667
}
6768
}
6869

69-
pub fn build(containerfile: &str, tag: &str, runner: &impl Runner) -> Result<(), BuildError> {
70+
pub fn build(
71+
containerfile: &str,
72+
tag: &str,
73+
runner: &impl Runner,
74+
context_dir: &Path,
75+
) -> Result<(), BuildError> {
7076
let mut tmpfile = NamedTempFile::new()?;
7177
tmpfile.write_all(containerfile.as_bytes())?;
7278

7379
log::debug!(
74-
"running: podman build -f {} -t {tag} .",
75-
tmpfile.path().display()
80+
"running: podman build -f {} -t {tag} {}",
81+
tmpfile.path().display(),
82+
context_dir.display()
7683
);
7784

7885
let mut cmd = Command::new("podman");
7986
cmd.args(["build", "-f"])
8087
.arg(tmpfile.path())
8188
.args(["-t", tag])
82-
.arg(".");
89+
.arg(context_dir);
8390

8491
let status = runner.run(&mut cmd)?;
8592

@@ -118,21 +125,21 @@ mod tests {
118125
#[test]
119126
fn build_succeeds() {
120127
let runner = FakeRunner(|| Ok(exit_with(0)));
121-
assert!(build(CONTAINERFILE, TAG, &runner).is_ok());
128+
assert!(build(CONTAINERFILE, TAG, &runner, Path::new(".")).is_ok());
122129
}
123130

124131
#[test]
125132
fn build_fails_with_exit_code() {
126133
let runner = FakeRunner(|| Ok(exit_with(1)));
127-
let err = build(CONTAINERFILE, TAG, &runner).unwrap_err();
134+
let err = build(CONTAINERFILE, TAG, &runner, Path::new(".")).unwrap_err();
128135
assert!(matches!(err, BuildError::Failed { exit_code: Some(1) }));
129136
}
130137

131138
#[test]
132139
fn build_propagates_io_error() {
133140
let runner =
134141
FakeRunner(|| Err(io::Error::new(io::ErrorKind::NotFound, "podman not found")));
135-
let err = build(CONTAINERFILE, TAG, &runner).unwrap_err();
142+
let err = build(CONTAINERFILE, TAG, &runner, Path::new(".")).unwrap_err();
136143
assert!(matches!(err, BuildError::Io(_)));
137144
}
138145

@@ -141,7 +148,7 @@ mod tests {
141148
fn build_signal_killed() {
142149
use std::os::unix::process::ExitStatusExt;
143150
let runner = FakeRunner(|| Ok(ExitStatus::from_raw(9)));
144-
let err = build(CONTAINERFILE, TAG, &runner).unwrap_err();
151+
let err = build(CONTAINERFILE, TAG, &runner, Path::new(".")).unwrap_err();
145152
assert!(matches!(err, BuildError::Failed { exit_code: None }));
146153
}
147154

0 commit comments

Comments
 (0)