ginkgo-oci 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ # Local environment and tool caches
2
+ .pixi/*
3
+ !.pixi/config.toml
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ ui/node_modules/
7
+ ui/.vite/
8
+ __pycache__/
9
+ *.py[cod]
10
+
11
+ *__pycache__*
12
+ # Ginkgo runtime state
13
+ .ginkgo/
14
+ examples/**/logs/
15
+ examples/**/results/
16
+ benchmarks/results/
17
+
18
+ # Agent / local-instructions files
19
+ AGENTS.md
20
+ # ...but not the one `ginkgo init` scaffolds into new projects.
21
+ !src/ginkgo/templates/init/agents/AGENTS.md
22
+ CLAUDE.md
23
+ .claude/
24
+ .agents/
25
+
26
+ # uncoded symbol index (regenerated locally via `uncoded sync`)
27
+ .uncoded/
28
+
29
+ # Local planning / design / worklog docs
30
+ plan.md
31
+ *plan.local.md
32
+ worklog.md
33
+ *worklog*.md
34
+ ginkgo-design-doc.md
35
+ example-workflow.py
36
+ docs/_build/
37
+ docs/site/node_modules/
38
+ docs/site/dist/
39
+ docs/site/.astro/
40
+
41
+ # OS/editor noise
42
+ .DS_Store
43
+
44
+ *.lock
45
+ # Env lockfiles that are part of a fixture or example are inputs, not build
46
+ # output: an example whose lock is missing re-solves on first use, which
47
+ # changes its env identity between runs and silently breaks cache reuse.
48
+ !tests/**/pixi.lock
49
+ !plugins/**/pixi.lock
50
+ !examples/**/pixi.lock
51
+
52
+ # Maintainer working documents (CLAUDE.md: plans are not committed)
53
+ docs/*-plan.md
54
+ docs/user-testing-*.md
55
+ docs/oke-validation.md
56
+ docs/*.pptx
57
+ scratchpad/
58
+ .vscode/
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: ginkgo-oci
3
+ Version: 0.4.0
4
+ Summary: Oracle Cloud integrations for ginkgo: the oci:// object store and rclone driver
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: ginkgo-workflow==0.4.0
8
+ Requires-Dist: ocifs>=1.3.2
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ginkgo-oci
12
+
13
+ Oracle Cloud integrations for ginkgo: the oci:// object store and rclone driver.
14
+
15
+ A plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install "ginkgo-workflow[oci]"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "ginkgo-oci"
3
+ version = "0.4.0"
4
+ description = "Oracle Cloud integrations for ginkgo: the oci:// object store and rclone driver"
5
+ requires-python = ">= 3.11"
6
+ readme = { text = "# ginkgo-oci\n\nOracle Cloud integrations for ginkgo: the oci:// object store and rclone driver.\n\nA plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install \"ginkgo-workflow[oci]\"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).", content-type = "text/markdown" }
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "ginkgo-workflow==0.4.0",
10
+ "ocifs>=1.3.2",
11
+ ]
12
+
13
+ [project.entry-points."ginkgo.plugins"]
14
+ oci = "ginkgo_oci:PLUGIN"
15
+
16
+ [build-system]
17
+ build-backend = "hatchling.build"
18
+ requires = ["hatchling"]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/ginkgo_oci"]
@@ -0,0 +1,17 @@
1
+ """ginkgo-oci: the ``oci://`` object store and the rclone FUSE driver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ginkgo.plugins.api import MountDriverType, ObjectStoreType, Plugin
6
+ from ginkgo_oci.rclone import RcloneDriver
7
+ from ginkgo_oci.store import build_oci_store, filesystem, parse_oci_uri
8
+
9
+ __all__ = ["PLUGIN", "filesystem"]
10
+
11
+ PLUGIN = Plugin(
12
+ name="ginkgo-oci",
13
+ contributions=(
14
+ ObjectStoreType(scheme="oci", parse_uri=parse_oci_uri, build=build_oci_store),
15
+ MountDriverType(scheme="oci", build=RcloneDriver),
16
+ ),
17
+ )
@@ -0,0 +1,106 @@
1
+ """``rclone mount`` driver wrapper, ginkgo-oci's driver for ``oci://`` mounts.
2
+
3
+ rclone's per-remote configuration is the user's responsibility; we assume a
4
+ remote named ``<scheme>`` is configured.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from ginkgo.remote.access.drivers.base import (
15
+ DriverUnavailableError,
16
+ MountFailedError,
17
+ MountSpec,
18
+ unmount_fuse,
19
+ )
20
+
21
+
22
+ class RcloneDriver:
23
+ """Thin subprocess wrapper around ``rclone mount``."""
24
+
25
+ name = "rclone"
26
+
27
+ def __init__(self, *, binary: str = "rclone", remote: str | None = None) -> None:
28
+ self._binary = binary
29
+ self._remote = remote
30
+
31
+ def health_check(self) -> None:
32
+ """Verify ``rclone`` is on PATH."""
33
+ if shutil.which(self._binary) is None:
34
+ raise DriverUnavailableError(
35
+ f"{self._binary!r} binary not found on PATH. "
36
+ "Install rclone or use the worker-fuse image."
37
+ )
38
+
39
+ def mount(self, *, spec: MountSpec) -> int:
40
+ """Mount ``<remote>:<bucket>`` at ``spec.mount_point``."""
41
+ spec.mount_point.mkdir(parents=True, exist_ok=True)
42
+ remote_name = self._remote or spec.scheme
43
+
44
+ # OCI refs carry the namespace in the bucket component
45
+ # ("bucket@namespace"), but rclone's oracleobjectstorage backend
46
+ # takes the namespace from the remote's config and rejects "@" in
47
+ # bucket names. Mount only the bucket component; the configured
48
+ # remote must point at the matching namespace.
49
+ bucket = spec.bucket.split("@", 1)[0]
50
+
51
+ cmd = [
52
+ self._binary,
53
+ "mount",
54
+ f"{remote_name}:{bucket}",
55
+ str(spec.mount_point),
56
+ "--daemon",
57
+ ]
58
+ if spec.read_only:
59
+ cmd.append("--read-only")
60
+ if spec.cache_dir is not None:
61
+ spec.cache_dir.mkdir(parents=True, exist_ok=True)
62
+ cmd += [
63
+ "--cache-dir",
64
+ str(spec.cache_dir),
65
+ "--vfs-cache-mode",
66
+ "full",
67
+ ]
68
+ if spec.cache_max_bytes is not None:
69
+ cmd += [
70
+ "--vfs-cache-max-size",
71
+ f"{max(1, spec.cache_max_bytes // (1024 * 1024))}M",
72
+ ]
73
+ cmd += list(spec.extra_args)
74
+
75
+ try:
76
+ result = subprocess.run(
77
+ cmd,
78
+ check=False,
79
+ stdout=subprocess.PIPE,
80
+ stderr=subprocess.PIPE,
81
+ )
82
+ except FileNotFoundError as exc:
83
+ raise DriverUnavailableError(str(exc)) from exc
84
+
85
+ if result.returncode != 0:
86
+ raise MountFailedError(
87
+ "rclone mount failed: "
88
+ f"rc={result.returncode} stderr={result.stderr.decode(errors='replace')}"
89
+ )
90
+
91
+ # The daemon exits 0 even when the remote is misconfigured (bad
92
+ # bucket, bad credentials) and reads then fail with EIO. Probe the
93
+ # mount with one directory listing so a broken mount surfaces as
94
+ # MountFailedError, letting hydration fall back to staging.
95
+ try:
96
+ os.listdir(spec.mount_point)
97
+ except OSError as exc:
98
+ self.unmount(mount_point=spec.mount_point)
99
+ raise MountFailedError(
100
+ f"rclone mount at {spec.mount_point} is not readable: {exc}"
101
+ ) from exc
102
+ return 0
103
+
104
+ def unmount(self, *, mount_point: Path) -> None:
105
+ """Unmount via the generic helper."""
106
+ unmount_fuse(mount_point=mount_point)
@@ -0,0 +1,166 @@
1
+ """The ``oci://`` object store, on ``ocifs``, and its URI layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from ginkgo.core.remote import RemoteLocation
10
+ from ginkgo.plugins.api import ObjectStoreContext
11
+ from ginkgo.remote.backend import ObjectStore
12
+ from ginkgo.remote.fsspec_backends import FsspecBackend
13
+ from ginkgo.remote.resolve import config_string, config_table, first_defined, resolve_backend
14
+
15
+ # How a store signs its requests, by the name ginkgo's config uses, with the
16
+ # ``iam_type`` ocifs takes for it.
17
+ AUTH_IAM_TYPES = {
18
+ "api_key": "api_key", # pragma: allowlist secret
19
+ "oke_workload_identity": "oke_principal",
20
+ "instance_principal": "instance_principal",
21
+ "resource_principal": "resource_principal",
22
+ }
23
+
24
+
25
+ class OCIFileSystemBackend(FsspecBackend):
26
+ """OCI Object Storage backend implemented with ``ocifs``.
27
+
28
+ Parameters
29
+ ----------
30
+ auth : str
31
+ How requests are signed: a key of :data:`AUTH_IAM_TYPES`. Only
32
+ ``api_key`` reads a config file.
33
+ config_path : str | None
34
+ Path to the OCI config file, for ``api_key`` auth.
35
+ profile : str | None
36
+ OCI config profile name, for ``api_key`` auth.
37
+ region : str | None
38
+ OCI region override.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ auth: str = "api_key",
45
+ config_path: str | None = None,
46
+ profile: str | None = None,
47
+ region: str | None = None,
48
+ ) -> None:
49
+ super().__init__(scheme="oci")
50
+ if auth not in AUTH_IAM_TYPES:
51
+ raise ValueError(
52
+ f"Unknown OCI auth {auth!r}; expected one of {', '.join(sorted(AUTH_IAM_TYPES))}"
53
+ )
54
+ self._auth = auth
55
+ self._config_path = config_path
56
+ self._profile = profile
57
+ self._region = region
58
+
59
+ def filesystem(self) -> Any:
60
+ """Return the ``ocifs.OCIFileSystem`` this store reads and writes through."""
61
+ return self._get_filesystem()
62
+
63
+ def _get_filesystem(self) -> Any:
64
+ if self._filesystem is not None:
65
+ return self._filesystem
66
+
67
+ # Imported on first use: the plugin module loads on every CLI run.
68
+ import ocifs
69
+
70
+ kwargs: dict[str, Any] = {"iam_type": AUTH_IAM_TYPES[self._auth]}
71
+ if self._auth == "api_key":
72
+ if self._config_path is not None:
73
+ kwargs["config"] = os.path.expanduser(self._config_path)
74
+ if self._profile is not None:
75
+ kwargs["profile"] = self._profile
76
+ if self._region is not None:
77
+ kwargs["region"] = self._region
78
+
79
+ self._filesystem = ocifs.OCIFileSystem(**kwargs)
80
+ return self._filesystem
81
+
82
+
83
+ def filesystem() -> Any:
84
+ """Return an ``ocifs.OCIFileSystem`` signed the way the ``oci://`` store is.
85
+
86
+ For workflow code that reads or writes OCI Object Storage itself, such as a
87
+ task uploading its results to a staging prefix, so it uses the auth, profile
88
+ and region the run is configured with.
89
+
90
+ Returns
91
+ -------
92
+ ocifs.OCIFileSystem
93
+ The configured filesystem.
94
+ """
95
+ backend = resolve_backend("oci")
96
+ assert isinstance(backend, OCIFileSystemBackend)
97
+ return backend.filesystem()
98
+
99
+
100
+ def build_oci_store(context: ObjectStoreContext) -> ObjectStore:
101
+ """Build the OCI store from ``GINKGO_REMOTE_OCI_*``/``OCI_*`` or ``[remote.oci]``."""
102
+ remote = config_table(context.config, "remote")
103
+ oci = config_table(remote, "oci")
104
+ return OCIFileSystemBackend(
105
+ auth=first_defined(
106
+ os.environ.get("GINKGO_REMOTE_OCI_AUTH"),
107
+ config_string(oci, "auth"),
108
+ )
109
+ or "api_key",
110
+ config_path=first_defined(
111
+ os.environ.get("GINKGO_REMOTE_OCI_CONFIG"),
112
+ os.environ.get("OCI_CONFIG_FILE"),
113
+ config_string(oci, "config"),
114
+ "~/.oci/config",
115
+ ),
116
+ profile=first_defined(
117
+ os.environ.get("GINKGO_REMOTE_OCI_PROFILE"),
118
+ os.environ.get("OCI_CONFIG_PROFILE"),
119
+ config_string(oci, "profile"),
120
+ ),
121
+ region=context.region
122
+ or first_defined(
123
+ os.environ.get("GINKGO_REMOTE_OCI_REGION"),
124
+ os.environ.get("OCI_REGION"),
125
+ config_string(oci, "region"),
126
+ config_string(remote, "region"),
127
+ ),
128
+ )
129
+
130
+
131
+ def parse_oci_uri(uri: str) -> RemoteLocation:
132
+ """Parse an OCI Object Storage URI.
133
+
134
+ Both forms are accepted: ``oci://namespace/bucket/key`` and
135
+ ``oci://bucket@namespace/key``. The bucket is normalised to
136
+ ``"<bucket>@<namespace>"``, the authority ``ocifs`` addresses.
137
+
138
+ Raises
139
+ ------
140
+ ValueError
141
+ If the namespace, bucket or key is missing.
142
+ """
143
+ parsed = urlparse(uri)
144
+ authority = parsed.netloc
145
+ if not authority:
146
+ raise ValueError(f"OCI URI missing namespace or bucket: {uri!r}")
147
+
148
+ form = "oci://namespace/bucket/key or oci://bucket@namespace/key"
149
+ key = parsed.path.lstrip("/")
150
+ if not key:
151
+ raise ValueError(f"OCI URI must have the form {form}: {uri!r}")
152
+
153
+ if "@" in authority:
154
+ bucket, namespace = authority.split("@", 1)
155
+ if not bucket or not namespace:
156
+ raise ValueError(f"OCI URI must have the form oci://bucket@namespace/key: {uri!r}")
157
+ return RemoteLocation(
158
+ scheme="oci", bucket=f"{bucket}@{namespace}", key=key, namespace=namespace
159
+ )
160
+
161
+ bucket, _, object_key = key.partition("/")
162
+ if not object_key:
163
+ raise ValueError(f"OCI URI must have the form {form}: {uri!r}")
164
+ return RemoteLocation(
165
+ scheme="oci", bucket=f"{bucket}@{authority}", key=object_key, namespace=authority
166
+ )
@@ -0,0 +1,117 @@
1
+ """The oci:// object store contributed by ginkgo-oci, and its URI layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from ginkgo.core.remote import RemoteFileRef, RemoteFolderRef, remote_file, remote_folder
8
+ from ginkgo.remote.fsspec_backends import FsspecBackend
9
+ from ginkgo.remote.resolve import resolve_backend
10
+ from ginkgo_oci.store import OCIFileSystemBackend
11
+
12
+
13
+ class TestOCIUris:
14
+ def test_oci_uri(self) -> None:
15
+ ref = remote_file("oci://mynamespace/mybucket/path/to/object.csv")
16
+ assert isinstance(ref, RemoteFileRef)
17
+ assert ref.scheme == "oci"
18
+ assert ref.bucket == "mybucket@mynamespace"
19
+ assert ref.namespace == "mynamespace"
20
+ assert ref.key == "path/to/object.csv"
21
+
22
+ def test_oci_uri_nested_key(self) -> None:
23
+ ref = remote_file("oci://ns/bkt/a/b/c.txt")
24
+ assert ref.bucket == "bkt@ns"
25
+ assert ref.namespace == "ns"
26
+ assert ref.key == "a/b/c.txt"
27
+
28
+ def test_oci_bucket_at_namespace_uri(self) -> None:
29
+ ref = remote_file("oci://mybucket@mynamespace/path/to/object.csv")
30
+ assert ref.bucket == "mybucket@mynamespace"
31
+ assert ref.namespace == "mynamespace"
32
+ assert ref.key == "path/to/object.csv"
33
+
34
+ def test_oci_missing_namespace_raises(self) -> None:
35
+ with pytest.raises(ValueError, match="missing namespace"):
36
+ remote_file("oci:///bucket/key")
37
+
38
+ def test_oci_missing_key_raises(self) -> None:
39
+ with pytest.raises(ValueError, match="oci://namespace/bucket/key"):
40
+ remote_file("oci://namespace/bucket")
41
+
42
+ def test_oci_prefix(self) -> None:
43
+ ref = remote_folder("oci://ns/bkt/prefix/")
44
+ assert isinstance(ref, RemoteFolderRef)
45
+ assert ref.bucket == "bkt@ns"
46
+ assert ref.key == "prefix/"
47
+
48
+
49
+ class TestBuildOCIStore:
50
+ def test_profile_comes_from_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None:
51
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_PROFILE", "uk")
52
+
53
+ backend = resolve_backend("oci")
54
+
55
+ assert isinstance(backend, OCIFileSystemBackend)
56
+ assert isinstance(backend, FsspecBackend)
57
+ assert backend._scheme == "oci"
58
+ assert backend._profile == "uk"
59
+
60
+
61
+ class TestOCIAuth:
62
+ def _built_kwargs(self, monkeypatch: pytest.MonkeyPatch) -> dict:
63
+ """Return the keyword arguments the store passes to ``OCIFileSystem``."""
64
+ import ocifs
65
+
66
+ captured: dict = {}
67
+ monkeypatch.setattr(ocifs, "OCIFileSystem", lambda **kwargs: captured.update(kwargs))
68
+ resolve_backend("oci").filesystem()
69
+ return captured
70
+
71
+ def test_workload_identity_signs_without_a_config_file(
72
+ self, monkeypatch: pytest.MonkeyPatch
73
+ ) -> None:
74
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_AUTH", "oke_workload_identity")
75
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_REGION", "uk-london-1")
76
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_PROFILE", "ignored")
77
+
78
+ assert self._built_kwargs(monkeypatch) == {
79
+ "iam_type": "oke_principal",
80
+ "region": "uk-london-1",
81
+ }
82
+
83
+ def test_api_key_is_the_default_and_reads_the_config_file(
84
+ self, monkeypatch: pytest.MonkeyPatch
85
+ ) -> None:
86
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_CONFIG", "/etc/oci/config")
87
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_PROFILE", "uk")
88
+
89
+ assert self._built_kwargs(monkeypatch) == {
90
+ "iam_type": "api_key",
91
+ "config": "/etc/oci/config",
92
+ "profile": "uk",
93
+ }
94
+
95
+ def test_auth_can_come_from_config(self, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
96
+ (tmp_path / "ginkgo.toml").write_text(
97
+ '[remote.oci]\nauth = "instance_principal"\n', encoding="utf-8"
98
+ )
99
+ monkeypatch.chdir(tmp_path)
100
+
101
+ assert self._built_kwargs(monkeypatch)["iam_type"] == "instance_principal"
102
+
103
+ def test_an_unknown_auth_is_refused(self, monkeypatch: pytest.MonkeyPatch) -> None:
104
+ monkeypatch.setenv("GINKGO_REMOTE_OCI_AUTH", "password")
105
+
106
+ with pytest.raises(ValueError, match="Unknown OCI auth 'password'"):
107
+ resolve_backend("oci")
108
+
109
+ def test_filesystem_returns_the_configured_one(self, monkeypatch: pytest.MonkeyPatch) -> None:
110
+ import ocifs
111
+
112
+ from ginkgo_oci import filesystem
113
+
114
+ sentinel = object()
115
+ monkeypatch.setattr(ocifs, "OCIFileSystem", lambda **kwargs: sentinel)
116
+
117
+ assert filesystem() is sentinel
@@ -0,0 +1,61 @@
1
+ """The rclone FUSE driver ginkgo-oci uses for oci:// mounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import ginkgo_oci.rclone as rclone_mod
10
+ from ginkgo.remote.access.drivers.base import MountFailedError, MountSpec
11
+
12
+
13
+ class TestRcloneDriver:
14
+ def _spec(self, tmp_path: Path, *, bucket: str) -> MountSpec:
15
+ return MountSpec(
16
+ scheme="oci",
17
+ bucket=bucket,
18
+ mount_point=tmp_path / "mount",
19
+ cache_dir=None,
20
+ cache_max_bytes=None,
21
+ read_only=True,
22
+ )
23
+
24
+ def test_mount_strips_oci_namespace_from_bucket(self, tmp_path: Path, monkeypatch) -> None:
25
+ commands: list[list[str]] = []
26
+
27
+ def _fake_run(cmd, **kwargs): # noqa: ARG001
28
+ commands.append(cmd)
29
+
30
+ class _Result:
31
+ returncode = 0
32
+ stderr = b""
33
+
34
+ return _Result()
35
+
36
+ monkeypatch.setattr(rclone_mod.subprocess, "run", _fake_run)
37
+ driver = rclone_mod.RcloneDriver()
38
+ driver.mount(spec=self._spec(tmp_path, bucket="my-bucket@my-namespace"))
39
+ assert commands[0][2] == "oci:my-bucket"
40
+
41
+ def test_mount_probe_failure_raises_and_unmounts(self, tmp_path: Path, monkeypatch) -> None:
42
+ def _fake_run(cmd, **kwargs): # noqa: ARG001
43
+ class _Result:
44
+ returncode = 0
45
+ stderr = b""
46
+
47
+ return _Result()
48
+
49
+ def _fail_listdir(path): # noqa: ARG001
50
+ raise OSError(5, "Input/output error")
51
+
52
+ unmounted: list[Path] = []
53
+ monkeypatch.setattr(rclone_mod.subprocess, "run", _fake_run)
54
+ monkeypatch.setattr(rclone_mod.os, "listdir", _fail_listdir)
55
+ driver = rclone_mod.RcloneDriver()
56
+ monkeypatch.setattr(
57
+ driver, "unmount", lambda *, mount_point: unmounted.append(mount_point)
58
+ )
59
+ with pytest.raises(MountFailedError, match="not readable"):
60
+ driver.mount(spec=self._spec(tmp_path, bucket="my-bucket@my-namespace"))
61
+ assert unmounted == [tmp_path / "mount"]