Skip to content

Commit c3c5407

Browse files
pofallonclaude
andauthored
feat: lockfile graduation — wire writer in up's feature pipeline (PR-4b) (#42)
* feat: lockfile graduation — schema parity + CLI surface (PR-4a) Phase 1 of lockfile graduation per docs/ROADMAP_TO_1.0.md. Ships the schema parity fixes, the user-visible CLI flag surface, the writer helper, and upstream-aligned error messages. The actual writer integration (building a Lockfile from resolved features and writing it after `up`/`build`) is tracked as PR-4b at issue #32. ## Schema parity (deacon-core/lockfile.rs) - LockfileFeature.depends_on now serializes as `dependsOn` (camelCase) via #[serde(rename)], matching upstream `devcontainers/cli`'s generateLockfile in src/spec-configuration/lockfile.ts. Deserializer accepts the camelCase form on read. - write_lockfile() emits a trailing newline to match upstream `JSON.stringify(..., 2) + '\n'`. Byte-identical output keeps the --frozen-lockfile content comparison stable. - LockfileValidationResult::format_error() now emits upstream-aligned strings: "Lockfile does not exist." / "Lockfile does not match." as the leading summary line; trailing actionable guidance references the graduated --frozen-lockfile flag rather than the deprecated --experimental-frozen-lockfile. - New LockfileFeature::from_resolved() helper constructs entries in the upstream canonical form (resolved = "{registry}/{repo}@{digest}", integrity = "{digest}"). This is the writer entry point PR-4b uses. ## CLI surface graduation up + build both gain: - --no-lockfile (visible): skip lockfile generation and verification. - --frozen-lockfile (visible): require an up-to-date lockfile; fail if resolution would change it. Mutual exclusivity (--no-lockfile xor --frozen-lockfile) enforced in the CLI layer, mirroring upstream's pre-parse validation. Deprecation: - --experimental-lockfile and --experimental-frozen-lockfile remain accepted as hidden aliases through the 1.x line. The CLI emits a WARN on use directing users to the graduated flags. - effective_frozen = frozen_lockfile || experimental_frozen_lockfile matches upstream's effectiveFrozenLockfile coalescing. ## Downstream consumers - up/mod.rs frozen-validation path now reads args.frozen_lockfile (the effective value) instead of args.experimental_frozen_lockfile. No behavior change — only the variable name moves. - build/mod.rs args struct gains no_lockfile + frozen_lockfile fields (carrier-only for PR-4b; #[allow(dead_code)] until then). ## Tests Added in deacon-core/lockfile.rs: - test_depends_on_serializes_as_camel_case - test_write_lockfile_emits_trailing_newline - test_from_resolved_constructs_upstream_form Updated in deacon/tests/up_lockfile_frozen.rs (5 assertions): align with the new upstream-format error messages ("Lockfile does not exist." / "Lockfile does not match." / capital "F" on "Features ..." substrings). ## Verified - cargo fmt --all -- --check - cargo clippy --all-targets -- -D warnings - cargo nextest run --profile dev-fast --no-default-features: 1930/1930 pass - cargo test --doc --workspace: 130/130 pass ## Follow-up tracked - #32 — PR-4b: wire writer in up + build feature pipeline. - Build-command lockfile wiring is gated on the pre-existing TODO at build/mod.rs:1285 (build doesn't install features today). Will land with #32 or as a sibling PR. ## Refs - docs/ROADMAP_TO_1.0.md Tier 1 item "Lockfile graduation" - Upstream: devcontainers/cli#1212 (graduated lockfile in v0.87.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: lockfile graduation — wire writer in up's feature pipeline (PR-4b) Follow-up to PR-4a (#33): the user-visible flag surface, schema parity fix, and upstream-aligned error strings landed there, but the actual writer was never invoked. This wires it in. After feature resolution + download, build a Lockfile from the resolved feature set (`build_lockfile_from_features` mirrors upstream `generateLockfile`), thread it back via FeatureBuildOutput, then: - default: write `{config_dir}/devcontainer-lock.json` (sorted by key, trailing newline — byte-identical to upstream `writeLockfile`) - `--frozen-lockfile`: byte-compare against on-disk; fail with the upstream summary strings (`"Lockfile does not exist."` / `"Lockfile does not match."`) so existing CI scripts keep working - `--no-lockfile`: skip entirely - deprecated `--experimental-lockfile <PATH>`: still honored for the custom-path form (hidden alias path through the 1.x line) Wired into both single-container (`container.rs`) and compose (`compose.rs`) flows via shared `handle_lockfile_post_build` helper. EROFS/EACCES on the write path downgrades to a WARN so read-only workspaces (CI mounts, read-only volumes) don't break `up`. Lockfile keys are the user-provided feature ID (e.g. `ghcr.io/devcontainers/features/node:1`), not the canonical no-tag form — matching upstream and keeping the existing pre-build structural validation aligned. Build (`crates/deacon/src/commands/build/mod.rs`) is intentionally out of scope — see issue #32 and the standing TODO at build/mod.rs:1285. That's PR-4c. Closes #32 (PR-4 phase 2 of the lockfile graduation track). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cca87c3 commit c3c5407

4 files changed

Lines changed: 699 additions & 7 deletions

File tree

crates/deacon/src/commands/up/compose.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use super::args::{MountType, NormalizedMount, UpArgs};
99
use super::features_build::{
1010
build_image_with_features, build_image_with_features_from_dockerfile, FeatureBuildOutput,
1111
};
12+
use super::helpers::handle_lockfile_post_build;
1213
use super::lifecycle::{execute_initialize_command, resolve_force_pty};
1314
use super::merged_config::{
1415
build_merged_configuration_with_options, inspect_for_merged_configuration,
@@ -253,10 +254,8 @@ pub(crate) async fn execute_compose_up(
253254
// feature-extended image and rewriting the target service's `image:` via
254255
// the existing injection override. Both the `image:` shape (14a) and the
255256
// `build:` shape (14b — user-authored Dockerfile + context) are supported.
256-
// The returned FeatureBuildOutput is currently unused — combined feature env
257-
// is already pulled into the compose flow via the existing additional_env path.
258257
// Future work (per spec): thread resolved_features into merged_configuration.
259-
let _feature_build = install_features_for_compose(
258+
let feature_build = install_features_for_compose(
260259
config,
261260
&compose_manager,
262261
&mut project,
@@ -265,6 +264,14 @@ pub(crate) async fn execute_compose_up(
265264
)
266265
.await?;
267266

267+
// Lockfile graduation (PR-4b): mirror the single-container flow — write
268+
// the lockfile to disk, or byte-compare it in `--frozen-lockfile` mode.
269+
// Only runs when features were actually built (the compose path returns
270+
// `None` when no features are declared).
271+
if let Some(ref fb) = feature_build {
272+
handle_lockfile_post_build(args, config_path, &fb.lockfile)?;
273+
}
274+
268275
// Start the compose project
269276
// First, warn about security options that cannot be applied dynamically
270277
ComposeCommand::warn_security_options_for_compose(config);

crates/deacon/src/commands/up/container.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
use super::args::UpArgs;
88
use super::features_build::build_image_with_features;
9-
use super::helpers::apply_user_mapping;
9+
use super::helpers::{apply_user_mapping, handle_lockfile_post_build};
1010
use super::lifecycle::{execute_initialize_command, execute_lifecycle_commands};
1111
use super::merged_config::{
1212
build_merged_configuration_with_options, inspect_for_merged_configuration,
@@ -216,6 +216,12 @@ pub(crate) async fn execute_container_up(
216216
feature_build.image_tag
217217
);
218218

219+
// Lockfile graduation (PR-4b): write the freshly-built lockfile to disk
220+
// (or byte-compare it in `--frozen-lockfile` mode). Runs only when
221+
// features were actually resolved; with no features there is nothing
222+
// to lock.
223+
handle_lockfile_post_build(args, config_path, &feature_build.lockfile)?;
224+
219225
Some(feature_build.resolved_features)
220226
} else {
221227
None

0 commit comments

Comments
 (0)