syntara-plugin-sdk 0.1.0a1__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.
- syntara_plugin_sdk-0.1.0a1/.gitignore +66 -0
- syntara_plugin_sdk-0.1.0a1/PKG-INFO +30 -0
- syntara_plugin_sdk-0.1.0a1/README.md +7 -0
- syntara_plugin_sdk-0.1.0a1/pyproject.toml +49 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/__init__.py +109 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/artifact.py +243 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/artifact_archive.py +356 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/authoring.py +1525 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/catalog.py +364 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/cli.py +1672 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/oci.py +64 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/py.typed +1 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/registry.py +638 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/settings.py +360 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/testgen.py +420 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/sdk/workload.py +142 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/__init__.py +99 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/bindings_factory.py +385 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/chaining.py +170 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/conformance.py +168 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/context_builder.py +119 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/equivalence.py +119 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/errors.py +7 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/invocation.py +405 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/loader.py +85 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/manifests.py +170 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/payloads.py +148 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/plugin.py +53 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/py.typed +0 -0
- syntara_plugin_sdk-0.1.0a1/src/syntara_plugin/testing/redaction.py +137 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
pip-wheel-metadata/
|
|
20
|
+
share/python-wheels/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.installed.cfg
|
|
23
|
+
*.egg
|
|
24
|
+
MANIFEST
|
|
25
|
+
|
|
26
|
+
# Virtual environments
|
|
27
|
+
venv/
|
|
28
|
+
ENV/
|
|
29
|
+
env/
|
|
30
|
+
.venv
|
|
31
|
+
|
|
32
|
+
# IDEs
|
|
33
|
+
.vscode/
|
|
34
|
+
.idea/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
*~
|
|
38
|
+
.DS_Store
|
|
39
|
+
|
|
40
|
+
# Testing
|
|
41
|
+
.pytest_cache/
|
|
42
|
+
.coverage
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.nox/
|
|
46
|
+
|
|
47
|
+
# uv
|
|
48
|
+
.uv/
|
|
49
|
+
|
|
50
|
+
# Step SDK specific
|
|
51
|
+
*.compiled.json
|
|
52
|
+
step-definition.json
|
|
53
|
+
examples/*/step-definition.json
|
|
54
|
+
|
|
55
|
+
# Databases
|
|
56
|
+
*.db
|
|
57
|
+
*.sqlite
|
|
58
|
+
*.sqlite3
|
|
59
|
+
|
|
60
|
+
# Logs
|
|
61
|
+
*.log
|
|
62
|
+
|
|
63
|
+
# Environment variables
|
|
64
|
+
.env
|
|
65
|
+
.env.local
|
|
66
|
+
coverage.xml
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: syntara-plugin-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Authoring SDK for Syntara plugins, including the local test harness
|
|
5
|
+
Project-URL: Homepage, https://github.com/syntara-orchestration/syntara-plugin-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/syntara-orchestration/syntara-plugin-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/syntara-orchestration/syntara-plugin-sdk/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: pyyaml>=6.0
|
|
19
|
+
Requires-Dist: syntara-plugin-contracts==0.1.0a1
|
|
20
|
+
Provides-Extra: testing
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'testing'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Syntara Plugin SDK
|
|
25
|
+
|
|
26
|
+
`syntara-plugin-sdk` installs the `syntara-plugin` command-line interface for
|
|
27
|
+
creating, validating, packaging, inspecting, and publishing Syntara plugins.
|
|
28
|
+
|
|
29
|
+
See the [Syntara Plugin SDK repository](https://github.com/syntara-orchestration/syntara-plugin-sdk)
|
|
30
|
+
for installation, authoring guides, and the normative plugin contracts.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Syntara Plugin SDK
|
|
2
|
+
|
|
3
|
+
`syntara-plugin-sdk` installs the `syntara-plugin` command-line interface for
|
|
4
|
+
creating, validating, packaging, inspecting, and publishing Syntara plugins.
|
|
5
|
+
|
|
6
|
+
See the [Syntara Plugin SDK repository](https://github.com/syntara-orchestration/syntara-plugin-sdk)
|
|
7
|
+
for installation, authoring guides, and the normative plugin contracts.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "syntara-plugin-sdk"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Authoring SDK for Syntara plugins, including the local test harness"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"PyYAML>=6.0",
|
|
10
|
+
"syntara-plugin-contracts==0.1.0a1",
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/syntara-orchestration/syntara-plugin-sdk"
|
|
25
|
+
Repository = "https://github.com/syntara-orchestration/syntara-plugin-sdk"
|
|
26
|
+
Issues = "https://github.com/syntara-orchestration/syntara-plugin-sdk/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
testing = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.entry-points.pytest11]
|
|
34
|
+
syntara_plugin_testing = "syntara_plugin.testing.plugin"
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
syntara-plugin-contracts = { workspace = true }
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
syntara-plugin = "syntara_plugin.sdk.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
# Keep release builds reproducible; upgrades are reviewed like any other
|
|
44
|
+
# build-tool supply-chain change.
|
|
45
|
+
requires = ["hatchling==1.32.4"]
|
|
46
|
+
build-backend = "hatchling.build"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/syntara_plugin"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Authoring SDK package boundary for Syntara plugins."""
|
|
2
|
+
|
|
3
|
+
from syntara_plugin.contracts import ContractBundle, load_bundle
|
|
4
|
+
|
|
5
|
+
from .artifact import (
|
|
6
|
+
OCI_ARTIFACT_TYPE,
|
|
7
|
+
OCI_CONFIG_MEDIA_TYPE,
|
|
8
|
+
OCI_CONTENT_BUNDLE_MEDIA_TYPE,
|
|
9
|
+
OCI_PLUGIN_MANIFEST_MEDIA_TYPE,
|
|
10
|
+
ArtifactBuildError,
|
|
11
|
+
PluginArtifact,
|
|
12
|
+
build_plugin_artifact,
|
|
13
|
+
)
|
|
14
|
+
from .artifact_archive import (
|
|
15
|
+
OCI_LAYOUT_VERSION,
|
|
16
|
+
ArtifactArchiveError,
|
|
17
|
+
read_plugin_artifact_archive,
|
|
18
|
+
write_plugin_artifact_archive,
|
|
19
|
+
)
|
|
20
|
+
from .authoring import (
|
|
21
|
+
BuildRequest,
|
|
22
|
+
CompilationError,
|
|
23
|
+
CompilationResult,
|
|
24
|
+
CompiledAsset,
|
|
25
|
+
Diagnostic,
|
|
26
|
+
Plugin,
|
|
27
|
+
SourceLocation,
|
|
28
|
+
Target,
|
|
29
|
+
compile_model,
|
|
30
|
+
compile_workspace,
|
|
31
|
+
)
|
|
32
|
+
from .catalog import (
|
|
33
|
+
OCI_CATALOG_INDEX_ARTIFACT_TYPE,
|
|
34
|
+
OCI_CATALOG_INDEX_CONFIG_MEDIA_TYPE,
|
|
35
|
+
OCI_CATALOG_INDEX_CONTENT_MEDIA_TYPE,
|
|
36
|
+
CatalogIndexArtifact,
|
|
37
|
+
CatalogIndexArtifactBuildError,
|
|
38
|
+
CatalogIndexUpdateError,
|
|
39
|
+
build_catalog_index_artifact,
|
|
40
|
+
merge_catalog_entry,
|
|
41
|
+
recover_catalog_entry,
|
|
42
|
+
)
|
|
43
|
+
from .oci import OCI_IMAGE_MANIFEST_MEDIA_TYPE, OciBlob, OciDescriptor
|
|
44
|
+
from .registry import (
|
|
45
|
+
CatalogIndexPublication,
|
|
46
|
+
CatalogIndexPublicationError,
|
|
47
|
+
CatalogIndexPublicationTarget,
|
|
48
|
+
CatalogIndexPublisher,
|
|
49
|
+
CatalogIndexSnapshot,
|
|
50
|
+
OciRegistryResponse,
|
|
51
|
+
OciRegistryTransport,
|
|
52
|
+
PluginArtifactPublication,
|
|
53
|
+
PluginArtifactPublicationError,
|
|
54
|
+
PluginArtifactPublicationTarget,
|
|
55
|
+
PluginArtifactPublisher,
|
|
56
|
+
RegistryCredentials,
|
|
57
|
+
UrllibOciRegistryTransport,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"ArtifactBuildError",
|
|
62
|
+
"ArtifactArchiveError",
|
|
63
|
+
"BuildRequest",
|
|
64
|
+
"CatalogIndexArtifact",
|
|
65
|
+
"CatalogIndexArtifactBuildError",
|
|
66
|
+
"CatalogIndexUpdateError",
|
|
67
|
+
"CatalogIndexPublication",
|
|
68
|
+
"CatalogIndexPublicationError",
|
|
69
|
+
"CatalogIndexPublicationTarget",
|
|
70
|
+
"CatalogIndexPublisher",
|
|
71
|
+
"CatalogIndexSnapshot",
|
|
72
|
+
"CompiledAsset",
|
|
73
|
+
"CompilationError",
|
|
74
|
+
"CompilationResult",
|
|
75
|
+
"ContractBundle",
|
|
76
|
+
"Diagnostic",
|
|
77
|
+
"OCI_ARTIFACT_TYPE",
|
|
78
|
+
"OCI_CATALOG_INDEX_ARTIFACT_TYPE",
|
|
79
|
+
"OCI_CATALOG_INDEX_CONFIG_MEDIA_TYPE",
|
|
80
|
+
"OCI_CATALOG_INDEX_CONTENT_MEDIA_TYPE",
|
|
81
|
+
"OCI_CONFIG_MEDIA_TYPE",
|
|
82
|
+
"OCI_CONTENT_BUNDLE_MEDIA_TYPE",
|
|
83
|
+
"OCI_IMAGE_MANIFEST_MEDIA_TYPE",
|
|
84
|
+
"OCI_LAYOUT_VERSION",
|
|
85
|
+
"OCI_PLUGIN_MANIFEST_MEDIA_TYPE",
|
|
86
|
+
"OciBlob",
|
|
87
|
+
"OciDescriptor",
|
|
88
|
+
"OciRegistryResponse",
|
|
89
|
+
"OciRegistryTransport",
|
|
90
|
+
"Plugin",
|
|
91
|
+
"PluginArtifact",
|
|
92
|
+
"PluginArtifactPublication",
|
|
93
|
+
"PluginArtifactPublicationError",
|
|
94
|
+
"PluginArtifactPublicationTarget",
|
|
95
|
+
"PluginArtifactPublisher",
|
|
96
|
+
"RegistryCredentials",
|
|
97
|
+
"SourceLocation",
|
|
98
|
+
"Target",
|
|
99
|
+
"UrllibOciRegistryTransport",
|
|
100
|
+
"build_plugin_artifact",
|
|
101
|
+
"build_catalog_index_artifact",
|
|
102
|
+
"merge_catalog_entry",
|
|
103
|
+
"recover_catalog_entry",
|
|
104
|
+
"compile_model",
|
|
105
|
+
"compile_workspace",
|
|
106
|
+
"load_bundle",
|
|
107
|
+
"read_plugin_artifact_archive",
|
|
108
|
+
"write_plugin_artifact_archive",
|
|
109
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Deterministic, offline OCI artifact assembly for compiled Syntara plugins.
|
|
2
|
+
|
|
3
|
+
This module deliberately stops before transport and trust operations. It turns
|
|
4
|
+
an already-validated :class:`CompilationResult` into content-addressed OCI
|
|
5
|
+
blobs; registry upload, signing, and policy verification remain separate
|
|
6
|
+
adapters owned by later increments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import tarfile
|
|
13
|
+
import unicodedata
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
from pathlib import PurePosixPath
|
|
19
|
+
|
|
20
|
+
from .authoring import CompilationResult, CompiledAsset
|
|
21
|
+
from .oci import OCI_IMAGE_MANIFEST_MEDIA_TYPE, OciBlob, canonical_json_bytes
|
|
22
|
+
|
|
23
|
+
OCI_ARTIFACT_TYPE = "application/vnd.syntara.plugin.v1"
|
|
24
|
+
OCI_CONFIG_MEDIA_TYPE = "application/vnd.syntara.plugin.config.v1+json"
|
|
25
|
+
OCI_PLUGIN_MANIFEST_MEDIA_TYPE = "application/vnd.syntara.plugin.manifest.v1+yaml"
|
|
26
|
+
OCI_CONTENT_BUNDLE_MEDIA_TYPE = "application/vnd.syntara.plugin.content.v1.tar+gzip"
|
|
27
|
+
|
|
28
|
+
_SCHEMA_SOURCE_FIELDS = frozenset({"configuration", "credentialSchema", "error", "input", "output"})
|
|
29
|
+
_HTTP_MAPPING_SOURCE_FIELDS = frozenset(
|
|
30
|
+
{"runtime/operation/requestMap", "runtime/operation/responseMap"}
|
|
31
|
+
)
|
|
32
|
+
_OCI_IMAGE_TITLE_ANNOTATION = "org.opencontainers.image.title"
|
|
33
|
+
_DOCUMENTATION_REFERENCE = "plugin.yaml#/spec/documentation"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ArtifactBuildError(ValueError):
|
|
37
|
+
"""Raised when an in-process compilation result cannot form a safe artifact."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PluginArtifact:
|
|
42
|
+
"""Offline OCI artifact ready for a later signing or registry adapter."""
|
|
43
|
+
|
|
44
|
+
config: OciBlob
|
|
45
|
+
plugin_manifest: OciBlob
|
|
46
|
+
content_bundle: OciBlob
|
|
47
|
+
manifest: OciBlob
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def digest(self) -> str:
|
|
51
|
+
"""Return the immutable OCI manifest digest used by catalogs/installers."""
|
|
52
|
+
|
|
53
|
+
return self.manifest.descriptor.digest
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def blobs(self) -> Mapping[str, OciBlob]:
|
|
57
|
+
"""Return every uploadable blob indexed by its verified digest."""
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
blob.descriptor.digest: blob
|
|
61
|
+
for blob in (self.config, self.plugin_manifest, self.content_bundle, self.manifest)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_plugin_artifact(compilation: CompilationResult) -> PluginArtifact:
|
|
66
|
+
"""Build a deterministic metadata-only OCI artifact from compiler output.
|
|
67
|
+
|
|
68
|
+
The plugin metadata is canonical JSON, which is valid YAML 1.2 and can
|
|
69
|
+
therefore safely use the versioned YAML media type. The content bundle
|
|
70
|
+
preserves exact source bytes for every externalized asset so consumers can
|
|
71
|
+
verify descriptor, config, and archive inventories independently.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
descriptor = compilation.descriptor
|
|
75
|
+
metadata = descriptor.get("metadata") if isinstance(descriptor, Mapping) else None
|
|
76
|
+
if descriptor.get("kind") != "PluginDescriptor" or not isinstance(metadata, Mapping):
|
|
77
|
+
raise ArtifactBuildError(
|
|
78
|
+
"Compilation result must contain a PluginDescriptor metadata mapping."
|
|
79
|
+
)
|
|
80
|
+
namespace = metadata.get("namespace")
|
|
81
|
+
name = metadata.get("name")
|
|
82
|
+
version = metadata.get("version")
|
|
83
|
+
if not all(isinstance(value, str) and value for value in (namespace, name, version)):
|
|
84
|
+
raise ArtifactBuildError(
|
|
85
|
+
"PluginDescriptor metadata must include namespace, name, and version."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
_validate_assets(compilation.assets)
|
|
89
|
+
plugin_manifest = OciBlob.create(
|
|
90
|
+
compilation.canonical_json(),
|
|
91
|
+
OCI_PLUGIN_MANIFEST_MEDIA_TYPE,
|
|
92
|
+
annotations={_OCI_IMAGE_TITLE_ANNOTATION: "plugin.manifest.yaml"},
|
|
93
|
+
)
|
|
94
|
+
content_bundle = OciBlob.create(
|
|
95
|
+
_build_content_bundle(compilation.assets),
|
|
96
|
+
OCI_CONTENT_BUNDLE_MEDIA_TYPE,
|
|
97
|
+
annotations={_OCI_IMAGE_TITLE_ANNOTATION: "content.tar.gz"},
|
|
98
|
+
)
|
|
99
|
+
config = OciBlob.create(
|
|
100
|
+
canonical_json_bytes(
|
|
101
|
+
{
|
|
102
|
+
"apiVersion": "syntara.plugin.config/v1alpha1",
|
|
103
|
+
"kind": "PluginArtifactConfig",
|
|
104
|
+
"pluginDescriptorDigest": compilation.digest,
|
|
105
|
+
"assetIndex": compilation.asset_index,
|
|
106
|
+
}
|
|
107
|
+
),
|
|
108
|
+
OCI_CONFIG_MEDIA_TYPE,
|
|
109
|
+
)
|
|
110
|
+
manifest = OciBlob.create(
|
|
111
|
+
canonical_json_bytes(
|
|
112
|
+
{
|
|
113
|
+
"schemaVersion": 2,
|
|
114
|
+
"mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
|
|
115
|
+
"artifactType": OCI_ARTIFACT_TYPE,
|
|
116
|
+
"config": config.descriptor.as_dict(),
|
|
117
|
+
"layers": [
|
|
118
|
+
plugin_manifest.descriptor.as_dict(),
|
|
119
|
+
content_bundle.descriptor.as_dict(),
|
|
120
|
+
],
|
|
121
|
+
"annotations": {
|
|
122
|
+
_OCI_IMAGE_TITLE_ANNOTATION: f"{namespace}/{name}",
|
|
123
|
+
"org.opencontainers.image.version": version,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
),
|
|
127
|
+
OCI_IMAGE_MANIFEST_MEDIA_TYPE,
|
|
128
|
+
)
|
|
129
|
+
return PluginArtifact(config, plugin_manifest, content_bundle, manifest)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_assets(assets: Mapping[str, CompiledAsset]) -> None:
|
|
133
|
+
seen_casefold: dict[str, str] = {}
|
|
134
|
+
for path, asset in assets.items():
|
|
135
|
+
_validate_asset(path, asset)
|
|
136
|
+
folded = path.casefold()
|
|
137
|
+
existing = seen_casefold.setdefault(folded, path)
|
|
138
|
+
if existing != path:
|
|
139
|
+
raise ArtifactBuildError(
|
|
140
|
+
f"Asset path {path!r} collides with {existing!r} on case-insensitive filesystems."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_asset(path: str, asset: CompiledAsset) -> None:
|
|
145
|
+
if path != asset.path:
|
|
146
|
+
raise ArtifactBuildError(
|
|
147
|
+
f"Asset index key {path!r} does not match asset path {asset.path!r}."
|
|
148
|
+
)
|
|
149
|
+
_validate_asset_path(path)
|
|
150
|
+
_validate_asset_content(path, asset)
|
|
151
|
+
_validate_asset_references(path, asset)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_asset_path(path: str) -> None:
|
|
155
|
+
parts = PurePosixPath(path).parts
|
|
156
|
+
invalid_path = any(
|
|
157
|
+
(
|
|
158
|
+
not path,
|
|
159
|
+
"\\" in path,
|
|
160
|
+
path.startswith("/"),
|
|
161
|
+
path != unicodedata.normalize("NFC", path),
|
|
162
|
+
len(path) > 1024,
|
|
163
|
+
_has_control_character(path),
|
|
164
|
+
_has_unsafe_path_part(parts),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
if invalid_path:
|
|
168
|
+
raise ArtifactBuildError(f"Asset path {path!r} is not a normalized relative POSIX path.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _has_control_character(path: str) -> bool:
|
|
172
|
+
return any(ord(character) < 32 or ord(character) == 127 for character in path)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _has_unsafe_path_part(parts: tuple[str, ...]) -> bool:
|
|
176
|
+
return any(part in {"", ".", ".."} or part.startswith(".") for part in parts)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _validate_asset_content(path: str, asset: CompiledAsset) -> None:
|
|
180
|
+
if f"sha256:{sha256(asset.content).hexdigest()}" != asset.source_digest:
|
|
181
|
+
raise ArtifactBuildError(f"Asset {path!r} source digest does not match its content.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validate_asset_references(path: str, asset: CompiledAsset) -> None:
|
|
185
|
+
if not asset.references or asset.references != tuple(sorted(set(asset.references))):
|
|
186
|
+
raise ArtifactBuildError(f"Asset {path!r} must have sorted, unique source references.")
|
|
187
|
+
for reference in asset.references:
|
|
188
|
+
_validate_asset_reference(path, asset.media_type, reference)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validate_asset_reference(path: str, media_type: str, reference: str) -> None:
|
|
192
|
+
if reference == _DOCUMENTATION_REFERENCE:
|
|
193
|
+
if media_type != "text/markdown" or not path.endswith(".md"):
|
|
194
|
+
raise ArtifactBuildError(
|
|
195
|
+
f"Asset {path!r} has an invalid documentation reference {reference!r}."
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
expected_media_type = _reference_media_type(path, reference)
|
|
199
|
+
if media_type != expected_media_type:
|
|
200
|
+
raise ArtifactBuildError(f"Asset {path!r} has an invalid media type for {reference!r}.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _reference_media_type(path: str, reference: str) -> str:
|
|
204
|
+
"""Return the one allowed media type for a typed asset reference."""
|
|
205
|
+
|
|
206
|
+
source_path, separator, field = reference.partition("#/spec/")
|
|
207
|
+
if not separator or not source_path:
|
|
208
|
+
raise ArtifactBuildError(f"Asset {path!r} has an invalid source reference {reference!r}.")
|
|
209
|
+
if field in _SCHEMA_SOURCE_FIELDS:
|
|
210
|
+
prefix = "application/schema+"
|
|
211
|
+
elif field in _HTTP_MAPPING_SOURCE_FIELDS:
|
|
212
|
+
prefix = "application/"
|
|
213
|
+
else:
|
|
214
|
+
raise ArtifactBuildError(f"Asset {path!r} has an invalid source reference {reference!r}.")
|
|
215
|
+
if path.endswith(".json"):
|
|
216
|
+
return f"{prefix}json"
|
|
217
|
+
if path.endswith((".yaml", ".yml")):
|
|
218
|
+
return f"{prefix}yaml"
|
|
219
|
+
if path.endswith(".md"):
|
|
220
|
+
kind = "schema" if field in _SCHEMA_SOURCE_FIELDS else "HTTP mapping"
|
|
221
|
+
raise ArtifactBuildError(f"Asset {path!r} has a non-{kind} media type for {reference!r}.")
|
|
222
|
+
raise ArtifactBuildError(f"Asset {path!r} must use a JSON, YAML, or Markdown filename.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _build_content_bundle(assets: Mapping[str, CompiledAsset]) -> bytes:
|
|
226
|
+
"""Create a reproducible gzip-compressed tar without filesystem extraction."""
|
|
227
|
+
|
|
228
|
+
output = BytesIO()
|
|
229
|
+
with gzip.GzipFile(fileobj=output, mode="wb", mtime=0, filename="") as compressed:
|
|
230
|
+
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
|
231
|
+
for path in sorted(assets):
|
|
232
|
+
asset = assets[path]
|
|
233
|
+
entry = tarfile.TarInfo(name=path)
|
|
234
|
+
entry.size = len(asset.content)
|
|
235
|
+
entry.mode = 0o644
|
|
236
|
+
entry.uid = 0
|
|
237
|
+
entry.gid = 0
|
|
238
|
+
entry.uname = ""
|
|
239
|
+
entry.gname = ""
|
|
240
|
+
entry.mtime = 0
|
|
241
|
+
entry.pax_headers = {}
|
|
242
|
+
archive.addfile(entry, BytesIO(asset.content))
|
|
243
|
+
return output.getvalue()
|