Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
e4a0066
Initial plan
Copilot Oct 2, 2025
60090df
Initial analysis: Add support for oci-archive format to local registry
Copilot Oct 2, 2025
57480f0
Implement dual format support for oci-dir and oci-archive
Copilot Oct 2, 2025
9a42b20
Add comprehensive tests and fix compilation issues
Copilot Oct 2, 2025
662821f
Complete dual format support with documentation and examples
Copilot Oct 2, 2025
9289b59
Drop bin/task
termoshtt Oct 3, 2025
b5ce379
Merge branch 'main' into copilot/fix-fb20201e-0bd6-464a-9e1b-49c3a488…
termoshtt Oct 3, 2025
ee8c1bf
Fix compilation and test issues for dual format support
termoshtt Oct 3, 2025
d72e064
Format
termoshtt Oct 3, 2025
3aebeda
Merge branch 'main' into copilot/fix-fb20201e-0bd6-464a-9e1b-49c3a488…
termoshtt Oct 3, 2025
91b1bac
Document local registry directory structure and path escaping
termoshtt Oct 3, 2025
70ab0cc
Refactor dual format tests with session-scoped fixture
termoshtt Oct 3, 2025
0e84c90
Refactor artifact builder to move format selection to ArtifactBuilder…
termoshtt Oct 3, 2025
c084ee6
Simplify artifact path API to single get_local_registry_path function
termoshtt Oct 3, 2025
2748e89
Remove redundant tests from artifact module
termoshtt Oct 3, 2025
000f035
Deprecate get_image_dir and update CLI to support dual formats
termoshtt Oct 3, 2025
283bb3b
Add local-registry-path command and deprecate image-directory
termoshtt Oct 3, 2025
419fbc3
Replace get_image_dir with get_local_registry_path in load/pull
termoshtt Oct 3, 2025
725ffd0
Add implementation plan for Artifact API refactoring
termoshtt Oct 3, 2025
88dc239
Add background context from PR #639 to TODO.md
termoshtt Oct 3, 2025
2d21374
Revise TODO.md
termoshtt Oct 3, 2025
b01a6ab
feat(experimental): Add experimental Artifact enum API (Phase 1)
termoshtt Oct 3, 2025
e395f8c
Add allow(deprecated) for get_image_dir
termoshtt Oct 3, 2025
a98d137
Phase 2
termoshtt Oct 3, 2025
4462f23
feat(experimental): Complete Artifact enum API (Phase 1-3)
termoshtt Oct 3, 2025
a01d864
feat(experimental): Add Builder::add_annotation method
termoshtt Oct 3, 2025
d7dd68b
docs: Update TODO.md - Mark Phase 1-3 as completed
termoshtt Oct 3, 2025
9db98bb
docs: Revise TODO.md - Change to gradual migration strategy
termoshtt Oct 3, 2025
ea97376
feat(python): Migrate Artifact to use experimental::artifact internally
termoshtt Oct 3, 2025
333fa14
Regenerate stub
termoshtt Oct 3, 2025
d3b8576
feat(python): Complete experimental Artifact API migration
termoshtt Oct 3, 2025
1d3d701
refactor(python): Remove legacy Artifact classes
termoshtt Oct 3, 2025
101128b
docs: Update TODO.md - Mark Phase 5 as complete
termoshtt Oct 3, 2025
f036aa3
feat(cli): Migrate CLI to use experimental::artifact
termoshtt Oct 3, 2025
7182a9d
docs: Update TODO.md - Mark Phase 4 CLI migration as complete
termoshtt Oct 3, 2025
3376516
Format
termoshtt Oct 3, 2025
73583cd
refactor: Replace ommx::artifact with ommx::experimental::artifact
termoshtt Oct 3, 2025
19e803f
refactor: Promote experimental::artifact to ommx::artifact
termoshtt Oct 3, 2025
308b16a
docs: Update ARTIFACT.md and CLAUDE.md with unified Artifact API usage
termoshtt Oct 3, 2025
6e5ace9
fix: Remove dead code and fix clippy warnings in artifact.rs
termoshtt Oct 3, 2025
40c1219
Fix example compilation errors after Artifact API refactoring
termoshtt Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions ARTIFACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,71 @@ OMMX Artifact is a collection of `config`, `layers`, and annotations.

Note that other annotations listed above are also allowed.
The key may not start with `org.ommx.v1.`, but must be a valid reverse domain name as specified by OCI specification.

Storage Formats
---------------

OMMX Local Registry supports two storage formats for artifacts:

### OCI Directory Format (oci-dir)

The traditional format where artifacts are stored as directory structures following the OCI Image Layout specification. Each artifact is stored as a directory containing:
- `oci-layout` file indicating OCI compliance
- `blobs/` directory containing content-addressable blobs
- `index.json` file with manifest references

This format is suitable for:
- Local development and testing
- File system-based storage
- Cases where individual blobs need direct access

### OCI Archive Format (oci-archive)

A single-file format where the entire artifact is packaged as a tar archive with `.ommx` extension. This format contains the same OCI structure but packaged for easier distribution.

This format is suitable for:
- Cloud object storage systems (AWS S3, Google Cloud Storage, etc.)
- Artifact distribution and sharing
- Backup and archiving scenarios
- Network transfer optimization

### Format Selection and Compatibility

- **New artifacts default to oci-archive format** for better cloud storage compatibility
- **Both formats are fully supported** for loading and manipulation
- **Backward compatibility is maintained** - existing oci-dir artifacts continue to work
- **Format detection is automatic** - the system transparently handles both formats
- **Cross-format operations are supported** - you can load from one format and save to another

### Local Registry Directory Structure

Artifacts are stored in the local registry with the following path structure. Image names are converted to file system paths with `:` (colon) in tags escaped to `__` (double underscore):

**Example: `ghcr.io/jij-inc/ommx/qplib:3734`**

```
<LOCAL_REGISTRY_ROOT>/
└── ghcr.io/
└── jij-inc/
└── ommx/
└── qplib/
├── __3734/ # oci-dir format (: converted to __)
│ ├── oci-layout
│ ├── index.json
│ └── blobs/
│ └── sha256/...
└── __3734.ommx # oci-archive format (: converted to __)
```

**Path Conversion Rules:**
- Tags with `:` are escaped to `__` (e.g., `my-app:v1.0` → `my-app/__v1.0`)
- oci-dir format: `<escaped-path>/`
- oci-archive format: `<escaped-path>.ommx`

**Format Priority:**
When both formats exist for the same image name, **oci-archive format takes precedence**. The system checks for the `.ommx` file first, then falls back to the directory format.

### Migration Between Formats

The OMMX artifact system provides transparent format conversion. Artifacts can be loaded from either format and saved to either format without manual intervention. The system automatically detects the source format based on file system structure (directory vs. archive file) and handles the conversion internally.
55 changes: 55 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,61 @@ The project has completed its migration from Protocol Buffers auto-generated Pyt

**Implementation Details**: See actual code in `rust/ommx/src/` for current type definitions and API.

### Artifact API (Unified Format Handling) ✅

The project uses a unified `Artifact` enum API that handles both oci-dir and oci-archive formats transparently.

**Rust API:**
```rust
use ommx::artifact::{Artifact, Builder};

// Load from either format (automatic detection)
let mut artifact = Artifact::from_oci_archive(path)?;
let mut artifact = Artifact::from_oci_dir(path)?;
let mut artifact = Artifact::from_remote(image_name)?;
let artifact = Artifact::load(&image_name)?; // Auto-detect local or pull from remote

// Save to specific format
artifact.save()?; // Default: oci-archive to local registry
artifact.save_as_archive(path)?;
artifact.save_as_dir(path)?;

// Pull from remote
artifact.pull()?; // Saves as oci-archive to local registry

// Build new artifacts
let mut builder = Builder::new_archive(path, image_name)?;
let mut builder = Builder::for_github("org", "repo", "name", "tag")?;
builder.add_instance(instance, annotations)?;
builder.add_annotation("key".to_string(), "value".to_string());
let artifact = builder.build()?;
```

**Python API:**
```python
from ommx.artifact import Artifact, ArtifactBuilder

# Load from either format (automatic detection)
artifact = Artifact.load("image-name:tag")
artifact = Artifact.load_archive("path.ommx")
artifact = Artifact.load_dir("path/to/dir")

# Build new artifacts (defaults to oci-archive)
builder = ArtifactBuilder.new("image-name:tag")
builder.add_instance(instance)
builder.add_annotation("key", "value")
artifact = builder.build()

# For legacy oci-dir format
builder = ArtifactBuilder.new_dir(path, "image-name:tag")
```

**Key Points:**
- The API automatically detects and handles both oci-dir and oci-archive formats
- New artifacts default to oci-archive format for better cloud storage compatibility
- Format conversion happens transparently during load/save operations
- Python API maintains backward compatibility - no changes needed in user code

## Development Commands

This project uses [Taskfile](https://taskfile.dev/) for task management. **⚠️ All commands must be run from the project root directory.**
Expand Down
30 changes: 30 additions & 0 deletions python/ommx-tests/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
Global pytest configuration for ommx-tests.

This module sets up fixtures that are shared across all test modules.
"""

import tempfile
import shutil
from pathlib import Path

import pytest

from ommx.artifact import set_local_registry_root


@pytest.fixture(scope="session", autouse=True)
def setup_local_registry():
"""
Set up a temporary local registry for all tests.

This fixture is automatically used for all tests (autouse=True) and runs once
per test session (scope="session"). The local registry root can only be set
once per process due to OnceLock constraints in the Rust implementation.
"""
temp_dir = tempfile.mkdtemp(prefix="ommx-test-registry-")
try:
set_local_registry_root(temp_dir)
yield Path(temp_dir)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
105 changes: 105 additions & 0 deletions python/ommx-tests/tests/test_artifact_dual_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Test dual format support for OMMX artifacts (oci-dir and oci-archive).

This test validates that:
1. New artifacts default to oci-archive format
2. Both oci-dir and oci-archive formats can be loaded
3. Backward compatibility is maintained
4. get_artifact_path correctly identifies both formats
"""

import uuid

import pytest

from ommx.artifact import (
Artifact,
ArtifactBuilder,
get_local_registry_path,
)
from ommx.testing import SingleFeasibleLPGenerator, DataType


@pytest.fixture
def test_instance():
"""Create a test instance for artifact tests."""
generator = SingleFeasibleLPGenerator(3, DataType.INT)
return generator.get_v1_instance()


def test_new_artifacts_default_to_archive_format(test_instance):
"""Test that new artifacts created with ArtifactBuilder.new() use oci-archive format."""
image_name = f"test.local/archive-default:{uuid.uuid4()}"

# Build artifact using the default new() method
builder = ArtifactBuilder.new(image_name)
builder.add_instance(test_instance)
artifact = builder.build()

# Verify artifact is stored as oci-archive format (.ommx file)
base_path = get_local_registry_path(image_name)
archive_path = base_path.with_suffix(".ommx")
assert archive_path.exists()
assert archive_path.is_file()
assert archive_path.suffix == ".ommx"

# Verify we can load it back
loaded = Artifact.load(image_name)
assert loaded.image_name == image_name
assert len(loaded.layers) == len(artifact.layers)


def test_legacy_oci_dir_format_still_works(test_instance):
"""Test that the legacy oci-dir format can still be created and loaded."""
image_name = f"test.local/dir-legacy:{uuid.uuid4()}"

# Build artifact using the dir format via new_dir()
dir_path = get_local_registry_path(image_name)
builder = ArtifactBuilder.new_dir(dir_path, image_name)
builder.add_instance(test_instance)
artifact = builder.build()

# Verify artifact is stored as oci-dir format (directory)
assert dir_path.exists()
assert dir_path.is_dir()
assert (dir_path / "oci-layout").exists()

# Verify we can load it back
loaded = Artifact.load(image_name)
assert loaded.image_name == image_name
assert len(loaded.layers) == len(artifact.layers)


def test_dual_format_interoperability(test_instance):
"""Test that both formats work seamlessly together."""
archive_image = f"test.local/interop-archive:{uuid.uuid4()}"
dir_image = f"test.local/interop-dir:{uuid.uuid4()}"

# Create artifacts in both formats
archive_builder = ArtifactBuilder.new(archive_image)
archive_builder.add_instance(test_instance)
archive_builder.build()

dir_path = get_local_registry_path(dir_image)
dir_builder = ArtifactBuilder.new_dir(dir_path, dir_image)
dir_builder.add_instance(test_instance)
dir_builder.build()

# Both can be loaded with the same API
loaded_archive = Artifact.load(archive_image)
loaded_dir = Artifact.load(dir_image)
assert loaded_archive.image_name == archive_image
assert loaded_dir.image_name == dir_image

# get_local_registry_path returns base path, format checked via path suffix/directory
archive_base_path = get_local_registry_path(archive_image)
archive_file = archive_base_path.with_suffix(".ommx")
assert archive_file.exists() and archive_file.is_file()

dir_base_path = get_local_registry_path(dir_image)
assert dir_base_path.exists() and dir_base_path.is_dir()

# Non-existent artifacts don't have files/directories
nonexistent_base = get_local_registry_path(f"test.local/nonexistent:{uuid.uuid4()}")
assert not nonexistent_base.exists()
assert not nonexistent_base.with_suffix(".ommx").exists()
Loading
Loading