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.
- ginkgo_oci-0.4.0/.gitignore +58 -0
- ginkgo_oci-0.4.0/PKG-INFO +15 -0
- ginkgo_oci-0.4.0/pyproject.toml +21 -0
- ginkgo_oci-0.4.0/src/ginkgo_oci/__init__.py +17 -0
- ginkgo_oci-0.4.0/src/ginkgo_oci/rclone.py +106 -0
- ginkgo_oci-0.4.0/src/ginkgo_oci/store.py +166 -0
- ginkgo_oci-0.4.0/tests/test_oci_store.py +117 -0
- ginkgo_oci-0.4.0/tests/test_rclone.py +61 -0
|
@@ -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"]
|