dossierdock 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shashank Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: dossierdock
3
+ Version: 0.1.0
4
+ Summary: Release-readiness evidence and CI workflow generation for containerized AI services.
5
+ Author: Shashank Singh
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Shashank Singh
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/shasanksingh/dossierdock
29
+ Project-URL: Issues, https://github.com/shasanksingh/dossierdock/issues
30
+ Keywords: ai,containers,deployment,docker,ci-cd,mlops,release-evidence,security
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3 :: Only
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Software Development :: Build Tools
42
+ Requires-Python: >=3.11
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Provides-Extra: dev
46
+ Requires-Dist: build>=1.2; extra == "dev"
47
+ Requires-Dist: twine>=5.1; extra == "dev"
48
+ Dynamic: license-file
49
+
50
+ # DossierDock
51
+
52
+ `dossierdock` is a provider-neutral **release-evidence CLI for containerized AI services**. It does more than produce a build command: it checks the service contract, image hardening signals, health-route evidence, and local-secret exposure, then writes a portable release dossier for a pull request, CI artifact, or operational handover.
53
+
54
+ The first release deliberately does **not** own cloud credentials or send a deployment anywhere. That makes it a safe foundation for teams that want a clear, reviewable path from an AI service to a container build and CI pipeline. Remote targets (SSH, AWS, and other providers) can be added as opt-in adapters rather than built into every command.
55
+
56
+ ## Why it is different
57
+
58
+ Most deployment tools build or deploy an image. DossierDock focuses on answering: **“What evidence says this AI service is ready to release?”**
59
+
60
+ It produces a small, provider-neutral `release-dossier.json` containing only safe metadata: the service contract, health endpoint, manifest hash, blockers, review items, and checks performed. No API key, connection string, or secret value is collected or written to the dossier.
61
+
62
+ This makes the tool useful before choosing AWS, Docker, Kubernetes, a VM, or another delivery target.
63
+
64
+ ## What it does today
65
+
66
+ - Creates a portable `dossierdock.toml` manifest.
67
+ - Checks the Dockerfile, health endpoint, AI-service metadata, and deployment metadata before CI runs.
68
+ - Reviews root-container execution, missing Docker health checks, and common credential files that are not ignored.
69
+ - Looks for evidence that the declared HTTP health route exists in application source.
70
+ - Writes a portable release dossier suitable for CI artifacts and release reviews.
71
+ - Prints a deterministic build and release plan as text or JSON.
72
+ - Writes a GitHub Actions workflow that tests the project and builds an immutable container image.
73
+ - Uses only the Python standard library at runtime.
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ python -m pip install -e .
79
+ dossierdock init --path .
80
+ dossierdock doctor
81
+ dossierdock plan
82
+ dossierdock dossier
83
+ dossierdock workflow github --output .github/workflows/dossierdock.yml
84
+ ```
85
+
86
+ Add a health endpoint to your API, then build it locally:
87
+
88
+ ```bash
89
+ docker build -t my-ai-service:dev .
90
+ docker run --rm -p 8000:8000 my-ai-service:dev
91
+ ```
92
+
93
+ ## Manifest
94
+
95
+ ```toml
96
+ [app]
97
+ name = "my-ai-service"
98
+ dockerfile = "Dockerfile"
99
+ context = "."
100
+ image = "my-ai-service"
101
+ healthcheck = "/health"
102
+ port = 8000
103
+
104
+ [deployment]
105
+ target = "local"
106
+ environment = "development"
107
+
108
+ [ai]
109
+ service_kind = "ai-api"
110
+ model_provider = "external"
111
+ telemetry = "open-telemetry"
112
+ ```
113
+
114
+ The manifest contains no credentials. Keep provider credentials in the CI platform's secret store and add a target adapter only when you are ready to deploy.
115
+
116
+ ## Commands
117
+
118
+ | Command | Purpose |
119
+ | --- | --- |
120
+ | `dossierdock init` | Create a starter manifest. |
121
+ | `dossierdock doctor` | Report release-readiness issues and checks. |
122
+ | `dossierdock plan` | Show the container build and health-check plan. |
123
+ | `dossierdock dossier` | Write reviewable, secret-free release evidence as JSON. |
124
+ | `dossierdock workflow github` | Create a GitHub Actions CI workflow. |
125
+
126
+ ## Release dossier
127
+
128
+ ```bash
129
+ dossierdock dossier --output .dossierdock/release-dossier.json
130
+ ```
131
+
132
+ The dossier records the application, declared AI-service contract, health URL, SHA-256 fingerprint of the manifest, blockers, and review items. Generated GitHub Actions workflows upload it as a build artifact so the release evidence stays with the exact CI run that built the image.
133
+
134
+ ## Project layout
135
+
136
+ ```text
137
+ src/dossierdock/ CLI, config loader, checks, workflow templates
138
+ tests/ Standard-library unit tests
139
+ examples/ A deployable manifest example
140
+ docs/ Architecture and contributor notes
141
+ ```
142
+
143
+ ## Roadmap
144
+
145
+ 1. Docker build and local smoke-test adapters.
146
+ 2. OpenTelemetry-compatible deployment annotations and release events.
147
+ 3. SSH release adapter with rollback and health verification.
148
+ 4. Separate AWS deployment adapter and learning guide.
149
+
150
+ ## Contributing
151
+
152
+ See [CONTRIBUTING.md](CONTRIBUTING.md). This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,103 @@
1
+ # DossierDock
2
+
3
+ `dossierdock` is a provider-neutral **release-evidence CLI for containerized AI services**. It does more than produce a build command: it checks the service contract, image hardening signals, health-route evidence, and local-secret exposure, then writes a portable release dossier for a pull request, CI artifact, or operational handover.
4
+
5
+ The first release deliberately does **not** own cloud credentials or send a deployment anywhere. That makes it a safe foundation for teams that want a clear, reviewable path from an AI service to a container build and CI pipeline. Remote targets (SSH, AWS, and other providers) can be added as opt-in adapters rather than built into every command.
6
+
7
+ ## Why it is different
8
+
9
+ Most deployment tools build or deploy an image. DossierDock focuses on answering: **“What evidence says this AI service is ready to release?”**
10
+
11
+ It produces a small, provider-neutral `release-dossier.json` containing only safe metadata: the service contract, health endpoint, manifest hash, blockers, review items, and checks performed. No API key, connection string, or secret value is collected or written to the dossier.
12
+
13
+ This makes the tool useful before choosing AWS, Docker, Kubernetes, a VM, or another delivery target.
14
+
15
+ ## What it does today
16
+
17
+ - Creates a portable `dossierdock.toml` manifest.
18
+ - Checks the Dockerfile, health endpoint, AI-service metadata, and deployment metadata before CI runs.
19
+ - Reviews root-container execution, missing Docker health checks, and common credential files that are not ignored.
20
+ - Looks for evidence that the declared HTTP health route exists in application source.
21
+ - Writes a portable release dossier suitable for CI artifacts and release reviews.
22
+ - Prints a deterministic build and release plan as text or JSON.
23
+ - Writes a GitHub Actions workflow that tests the project and builds an immutable container image.
24
+ - Uses only the Python standard library at runtime.
25
+
26
+ ## Quick start
27
+
28
+ ```bash
29
+ python -m pip install -e .
30
+ dossierdock init --path .
31
+ dossierdock doctor
32
+ dossierdock plan
33
+ dossierdock dossier
34
+ dossierdock workflow github --output .github/workflows/dossierdock.yml
35
+ ```
36
+
37
+ Add a health endpoint to your API, then build it locally:
38
+
39
+ ```bash
40
+ docker build -t my-ai-service:dev .
41
+ docker run --rm -p 8000:8000 my-ai-service:dev
42
+ ```
43
+
44
+ ## Manifest
45
+
46
+ ```toml
47
+ [app]
48
+ name = "my-ai-service"
49
+ dockerfile = "Dockerfile"
50
+ context = "."
51
+ image = "my-ai-service"
52
+ healthcheck = "/health"
53
+ port = 8000
54
+
55
+ [deployment]
56
+ target = "local"
57
+ environment = "development"
58
+
59
+ [ai]
60
+ service_kind = "ai-api"
61
+ model_provider = "external"
62
+ telemetry = "open-telemetry"
63
+ ```
64
+
65
+ The manifest contains no credentials. Keep provider credentials in the CI platform's secret store and add a target adapter only when you are ready to deploy.
66
+
67
+ ## Commands
68
+
69
+ | Command | Purpose |
70
+ | --- | --- |
71
+ | `dossierdock init` | Create a starter manifest. |
72
+ | `dossierdock doctor` | Report release-readiness issues and checks. |
73
+ | `dossierdock plan` | Show the container build and health-check plan. |
74
+ | `dossierdock dossier` | Write reviewable, secret-free release evidence as JSON. |
75
+ | `dossierdock workflow github` | Create a GitHub Actions CI workflow. |
76
+
77
+ ## Release dossier
78
+
79
+ ```bash
80
+ dossierdock dossier --output .dossierdock/release-dossier.json
81
+ ```
82
+
83
+ The dossier records the application, declared AI-service contract, health URL, SHA-256 fingerprint of the manifest, blockers, and review items. Generated GitHub Actions workflows upload it as a build artifact so the release evidence stays with the exact CI run that built the image.
84
+
85
+ ## Project layout
86
+
87
+ ```text
88
+ src/dossierdock/ CLI, config loader, checks, workflow templates
89
+ tests/ Standard-library unit tests
90
+ examples/ A deployable manifest example
91
+ docs/ Architecture and contributor notes
92
+ ```
93
+
94
+ ## Roadmap
95
+
96
+ 1. Docker build and local smoke-test adapters.
97
+ 2. OpenTelemetry-compatible deployment annotations and release events.
98
+ 3. SSH release adapter with rollback and health verification.
99
+ 4. Separate AWS deployment adapter and learning guide.
100
+
101
+ ## Contributing
102
+
103
+ See [CONTRIBUTING.md](CONTRIBUTING.md). This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dossierdock"
7
+ version = "0.1.0"
8
+ description = "Release-readiness evidence and CI workflow generation for containerized AI services."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "Shashank Singh" },
14
+ ]
15
+ keywords = ["ai", "containers", "deployment", "docker", "ci-cd", "mlops", "release-evidence", "security"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Build Tools",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["build>=1.2", "twine>=5.1"]
33
+
34
+ [project.scripts]
35
+ dossierdock = "dossierdock.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/shasanksingh/dossierdock"
39
+ Issues = "https://github.com/shasanksingh/dossierdock/issues"
40
+
41
+ [tool.setuptools]
42
+ package-dir = { "" = "src" }
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """DossierDock: release-readiness evidence for AI services."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from dossierdock.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,153 @@
1
+ """Command-line interface for DossierDock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ from typing import Sequence
10
+
11
+ from dossierdock import __version__
12
+ from dossierdock.config import DEFAULT_CONFIG
13
+ from dossierdock.doctor import run_doctor
14
+ from dossierdock.evidence import build_dossier
15
+ from dossierdock.workflow import GITHUB_ACTIONS_WORKFLOW
16
+
17
+
18
+ def _write(path: Path, content: str, force: bool) -> None:
19
+ if path.exists() and not force:
20
+ raise FileExistsError(f"Refusing to overwrite {path}. Use --force to replace it.")
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(content, encoding="utf-8")
23
+
24
+
25
+ def _print_report(report, as_json: bool) -> None:
26
+ if as_json:
27
+ print(json.dumps(report.as_dict(), indent=2))
28
+ return
29
+ for check in report.checks:
30
+ print(f"[{check.status.upper():7}] {check.name}: {check.message}")
31
+ print("\nDossierDock is ready." if report.healthy else "\nDossierDock found issues to resolve.")
32
+
33
+
34
+ def _command_init(args: argparse.Namespace) -> int:
35
+ root = Path(args.path).expanduser().resolve()
36
+ destination = root / "dossierdock.toml"
37
+ try:
38
+ _write(destination, DEFAULT_CONFIG, args.force)
39
+ except FileExistsError as error:
40
+ print(error, file=sys.stderr)
41
+ return 2
42
+ print(f"Created {destination}")
43
+ return 0
44
+
45
+
46
+ def _command_doctor(args: argparse.Namespace) -> int:
47
+ report = run_doctor(args.config)
48
+ _print_report(report, args.json)
49
+ return 0 if report.healthy else 1
50
+
51
+
52
+ def _command_plan(args: argparse.Namespace) -> int:
53
+ report = run_doctor(args.config)
54
+ if not report.config:
55
+ _print_report(report, args.json)
56
+ return 1
57
+ config = report.config
58
+ plan = {
59
+ "application": config.name,
60
+ "environment": config.environment,
61
+ "target": config.target,
62
+ "build": {
63
+ "context": str(config.context_path),
64
+ "dockerfile": str(config.dockerfile_path),
65
+ "image": config.image,
66
+ },
67
+ "healthcheck": f"http://localhost:{config.port}{config.healthcheck}",
68
+ "ready": report.healthy,
69
+ }
70
+ if args.json:
71
+ print(json.dumps(plan, indent=2))
72
+ else:
73
+ print(f"Application: {plan['application']} ({plan['environment']})")
74
+ print(f"Target: {plan['target']}")
75
+ print(f"Build: docker build -f {config.dockerfile} -t {config.image} {config.context}")
76
+ print(f"Health: {plan['healthcheck']}")
77
+ print(f"Ready: {'yes' if report.healthy else 'no — run dossierdock doctor'}")
78
+ return 0 if report.healthy else 1
79
+
80
+
81
+ def _command_dossier(args: argparse.Namespace) -> int:
82
+ report = run_doctor(args.config)
83
+ dossier = build_dossier(report)
84
+ serialized = json.dumps(dossier, indent=2) + "\n"
85
+ if args.output == "-":
86
+ print(serialized, end="")
87
+ else:
88
+ destination = Path(args.output).expanduser().resolve()
89
+ try:
90
+ _write(destination, serialized, args.force)
91
+ except FileExistsError as error:
92
+ print(error, file=sys.stderr)
93
+ return 2
94
+ print(f"Created release dossier: {destination}")
95
+ return 0 if report.healthy else 1
96
+
97
+
98
+ def _command_workflow_github(args: argparse.Namespace) -> int:
99
+ destination = Path(args.output).expanduser().resolve()
100
+ try:
101
+ _write(destination, GITHUB_ACTIONS_WORKFLOW, args.force)
102
+ except FileExistsError as error:
103
+ print(error, file=sys.stderr)
104
+ return 2
105
+ print(f"Created GitHub Actions workflow: {destination}")
106
+ return 0
107
+
108
+
109
+ def build_parser() -> argparse.ArgumentParser:
110
+ parser = argparse.ArgumentParser(
111
+ prog="dossierdock",
112
+ description="Release-readiness evidence and CI workflow generation for containerized AI services.",
113
+ )
114
+ parser.add_argument("--version", action="version", version=f"dossierdock {__version__}")
115
+ subparsers = parser.add_subparsers(dest="command", required=True)
116
+
117
+ init_parser = subparsers.add_parser("init", help="Create a starter dossierdock.toml manifest.")
118
+ init_parser.add_argument("--path", default=".", help="Directory that will receive dossierdock.toml.")
119
+ init_parser.add_argument("--force", action="store_true", help="Replace an existing manifest.")
120
+ init_parser.set_defaults(handler=_command_init)
121
+
122
+ for name, handler, help_text in (
123
+ ("doctor", _command_doctor, "Check local container-delivery inputs."),
124
+ ("plan", _command_plan, "Show the generated build and health-check plan."),
125
+ ):
126
+ command = subparsers.add_parser(name, help=help_text)
127
+ command.add_argument("--config", help="Path to dossierdock.toml (defaults to the current directory).")
128
+ command.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
129
+ command.set_defaults(handler=handler)
130
+
131
+ dossier = subparsers.add_parser("dossier", help="Create a portable AI service release-evidence dossier.")
132
+ dossier.add_argument("--config", help="Path to dossierdock.toml (defaults to the current directory).")
133
+ dossier.add_argument(
134
+ "--output",
135
+ default=".dossierdock/release-dossier.json",
136
+ help="Dossier destination, or '-' to print JSON to stdout.",
137
+ )
138
+ dossier.add_argument("--force", action="store_true", help="Replace an existing dossier.")
139
+ dossier.set_defaults(handler=_command_dossier)
140
+
141
+ workflow = subparsers.add_parser("workflow", help="Generate CI workflow files.")
142
+ workflow_subparsers = workflow.add_subparsers(dest="workflow_command", required=True)
143
+ github = workflow_subparsers.add_parser("github", help="Write a GitHub Actions CI workflow.")
144
+ github.add_argument("--output", default=".github/workflows/dossierdock.yml", help="Workflow destination.")
145
+ github.add_argument("--force", action="store_true", help="Replace an existing workflow.")
146
+ github.set_defaults(handler=_command_workflow_github)
147
+ return parser
148
+
149
+
150
+ def main(argv: Sequence[str] | None = None) -> int:
151
+ parser = build_parser()
152
+ arguments = parser.parse_args(argv)
153
+ return arguments.handler(arguments)
@@ -0,0 +1,125 @@
1
+ """Manifest loading and validation for DossierDock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ import re
8
+ import tomllib
9
+ from typing import Any
10
+
11
+
12
+ class ConfigError(ValueError):
13
+ """Raised when a DossierDock manifest is missing or invalid."""
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class AppConfig:
18
+ name: str
19
+ dockerfile: str
20
+ context: str
21
+ image: str
22
+ healthcheck: str
23
+ port: int
24
+ target: str
25
+ environment: str
26
+ service_kind: str
27
+ model_provider: str
28
+ telemetry: str
29
+ config_path: Path
30
+
31
+ @property
32
+ def project_root(self) -> Path:
33
+ return self.config_path.parent
34
+
35
+ @property
36
+ def dockerfile_path(self) -> Path:
37
+ return self.project_root / self.dockerfile
38
+
39
+ @property
40
+ def context_path(self) -> Path:
41
+ return self.project_root / self.context
42
+
43
+ def public_dict(self) -> dict[str, Any]:
44
+ """Return serialisable non-secret configuration for command output."""
45
+ data = asdict(self)
46
+ data["config_path"] = str(self.config_path)
47
+ return data
48
+
49
+
50
+ DEFAULT_CONFIG = """# DossierDock release-evidence manifest\n# Keep credentials in your CI secret store, never in this file.\n\n[app]\nname = \"my-ai-service\"\ndockerfile = \"Dockerfile\"\ncontext = \".\"\nimage = \"my-ai-service\"\nhealthcheck = \"/health\"\nport = 8000\n\n[deployment]\ntarget = \"local\"\nenvironment = \"development\"\n\n[ai]\n# Metadata only: never put API keys or connection strings here.\nservice_kind = \"ai-api\"\nmodel_provider = \"external\"\ntelemetry = \"open-telemetry\"\n"""
51
+
52
+
53
+ def default_config_path() -> Path:
54
+ return Path.cwd() / "dossierdock.toml"
55
+
56
+
57
+ def _table(document: dict[str, Any], key: str) -> dict[str, Any]:
58
+ value = document.get(key)
59
+ if not isinstance(value, dict):
60
+ raise ConfigError(f"Missing [{key}] section.")
61
+ return value
62
+
63
+
64
+ def _optional_table(document: dict[str, Any], key: str) -> dict[str, Any]:
65
+ value = document.get(key, {})
66
+ if not isinstance(value, dict):
67
+ raise ConfigError(f"[{key}] must be a table.")
68
+ return value
69
+
70
+
71
+ def _text(table: dict[str, Any], key: str, default: str | None = None) -> str:
72
+ value = table.get(key, default)
73
+ if not isinstance(value, str) or not value.strip():
74
+ raise ConfigError(f"'{key}' must be a non-empty string.")
75
+ return value.strip()
76
+
77
+
78
+ def _port(table: dict[str, Any]) -> int:
79
+ value = table.get("port", 8000)
80
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 65535:
81
+ raise ConfigError("'port' must be an integer between 1 and 65535.")
82
+ return value
83
+
84
+
85
+ def _safe_name(value: str) -> str:
86
+ if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", value):
87
+ raise ConfigError("'name' may contain lowercase letters, digits, dots, underscores, and hyphens only.")
88
+ return value
89
+
90
+
91
+ def load_config(path: str | Path | None = None) -> AppConfig:
92
+ """Load and validate a DossierDock TOML manifest."""
93
+ config_path = Path(path) if path else default_config_path()
94
+ config_path = config_path.expanduser().resolve()
95
+ if not config_path.is_file():
96
+ raise ConfigError(f"Manifest not found: {config_path}")
97
+
98
+ try:
99
+ with config_path.open("rb") as handle:
100
+ document = tomllib.load(handle)
101
+ except tomllib.TOMLDecodeError as error:
102
+ raise ConfigError(f"Invalid TOML in {config_path.name}: {error}") from error
103
+
104
+ app = _table(document, "app")
105
+ deployment = _table(document, "deployment")
106
+ ai = _optional_table(document, "ai")
107
+ name = _safe_name(_text(app, "name"))
108
+ healthcheck = _text(app, "healthcheck", "/health")
109
+ if not healthcheck.startswith("/"):
110
+ raise ConfigError("'healthcheck' must start with '/'.")
111
+
112
+ return AppConfig(
113
+ name=name,
114
+ dockerfile=_text(app, "dockerfile", "Dockerfile"),
115
+ context=_text(app, "context", "."),
116
+ image=_text(app, "image", name),
117
+ healthcheck=healthcheck,
118
+ port=_port(app),
119
+ target=_text(deployment, "target", "local"),
120
+ environment=_text(deployment, "environment", "development"),
121
+ service_kind=_text(ai, "service_kind", "ai-api"),
122
+ model_provider=_text(ai, "model_provider", "unspecified"),
123
+ telemetry=_text(ai, "telemetry", "unspecified"),
124
+ config_path=config_path,
125
+ )
@@ -0,0 +1,149 @@
1
+ """Preflight checks for container build inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, asdict
6
+ from pathlib import Path
7
+ import re
8
+
9
+ from dossierdock.config import AppConfig, ConfigError, load_config
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Check:
14
+ name: str
15
+ status: str
16
+ message: str
17
+
18
+ def as_dict(self) -> dict[str, str]:
19
+ return asdict(self)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class DoctorReport:
24
+ checks: tuple[Check, ...]
25
+ config: AppConfig | None = None
26
+
27
+ @property
28
+ def healthy(self) -> bool:
29
+ return all(check.status != "error" for check in self.checks)
30
+
31
+ def as_dict(self) -> dict[str, object]:
32
+ return {
33
+ "healthy": self.healthy,
34
+ "checks": [check.as_dict() for check in self.checks],
35
+ "config": self.config.public_dict() if self.config else None,
36
+ }
37
+
38
+
39
+ def _check_file(path: Path, name: str, required: bool) -> Check:
40
+ if path.is_file():
41
+ return Check(name, "ok", f"Found {path.name}.")
42
+ level = "error" if required else "warning"
43
+ tip = "Create it before building the image." if required else "Recommended to reduce build context size."
44
+ return Check(name, level, f"{path.name} was not found. {tip}")
45
+
46
+
47
+ def _docker_contract_checks(config: AppConfig) -> list[Check]:
48
+ """Check image hardening signals without executing Docker or the application."""
49
+ if not config.dockerfile_path.is_file():
50
+ return []
51
+ dockerfile = config.dockerfile_path.read_text(encoding="utf-8", errors="ignore")
52
+ checks: list[Check] = []
53
+ if re.search(r"^\s*USER\s+", dockerfile, flags=re.MULTILINE):
54
+ checks.append(Check("container_user", "ok", "Dockerfile declares a runtime user."))
55
+ else:
56
+ checks.append(Check("container_user", "warning", "Dockerfile has no USER instruction; review root execution."))
57
+ if re.search(r"^\s*HEALTHCHECK\s+", dockerfile, flags=re.MULTILINE):
58
+ checks.append(Check("container_healthcheck", "ok", "Dockerfile declares a container health check."))
59
+ else:
60
+ checks.append(
61
+ Check(
62
+ "container_healthcheck",
63
+ "warning",
64
+ "No Docker HEALTHCHECK found; DossierDock will retain the external HTTP health contract.",
65
+ )
66
+ )
67
+ return checks
68
+
69
+
70
+ def _health_route_check(config: AppConfig) -> Check:
71
+ """Look for the declared route in typical API source files without parsing frameworks."""
72
+ source_suffixes = {".py", ".js", ".ts", ".tsx", ".go", ".java"}
73
+ ignored = {".git", ".venv", "venv", "node_modules", "build", "dist", "__pycache__"}
74
+ marker = config.healthcheck.lower()
75
+ for candidate in config.project_root.rglob("*"):
76
+ if any(part in ignored for part in candidate.parts) or candidate.suffix.lower() not in source_suffixes:
77
+ continue
78
+ if candidate.is_file() and marker in candidate.read_text(encoding="utf-8", errors="ignore").lower():
79
+ return Check("health_route", "ok", f"Found '{config.healthcheck}' in {candidate.relative_to(config.project_root)}.")
80
+ return Check(
81
+ "health_route",
82
+ "warning",
83
+ f"Could not locate '{config.healthcheck}' in source files; verify the route during a smoke test.",
84
+ )
85
+
86
+
87
+ def _secret_exposure_check(config: AppConfig) -> Check:
88
+ """Flag local environment files that are not protected by .gitignore.
89
+
90
+ The check never prints values. It only reports the filenames that need review.
91
+ """
92
+ sensitive_names = (".env", ".env.local", ".env.production", "credentials.json", "service-account.json")
93
+ found = [name for name in sensitive_names if (config.project_root / name).is_file()]
94
+ if not found:
95
+ return Check("secret_exposure", "ok", "No common local credential files found in the project root.")
96
+
97
+ ignore_file = config.project_root / ".gitignore"
98
+ ignored_patterns = ignore_file.read_text(encoding="utf-8", errors="ignore") if ignore_file.is_file() else ""
99
+ unprotected = [name for name in found if name not in ignored_patterns and not (name.startswith(".env") and ".env" in ignored_patterns)]
100
+ if unprotected:
101
+ return Check("secret_exposure", "error", f"Sensitive local files are not ignored: {', '.join(unprotected)}.")
102
+ return Check("secret_exposure", "warning", f"Local credential files are ignored but should not be copied into images: {', '.join(found)}.")
103
+
104
+
105
+ def run_doctor(path: str | Path | None = None) -> DoctorReport:
106
+ """Run only local, non-invasive preflight checks."""
107
+ try:
108
+ config = load_config(path)
109
+ except ConfigError as error:
110
+ return DoctorReport((Check("manifest", "error", str(error)),))
111
+
112
+ checks: list[Check] = [Check("manifest", "ok", f"Loaded {config.config_path.name}.")]
113
+ checks.append(_check_file(config.dockerfile_path, "dockerfile", required=True))
114
+
115
+ if config.context_path.is_dir():
116
+ checks.append(Check("context", "ok", f"Build context: {config.context_path}"))
117
+ else:
118
+ checks.append(Check("context", "error", f"Build context not found: {config.context_path}"))
119
+
120
+ checks.append(_check_file(config.project_root / ".dockerignore", "dockerignore", required=False))
121
+ checks.extend(_docker_contract_checks(config))
122
+ checks.append(
123
+ Check(
124
+ "healthcheck",
125
+ "ok",
126
+ f"Application should answer on http://localhost:{config.port}{config.healthcheck}.",
127
+ )
128
+ )
129
+ checks.append(_health_route_check(config))
130
+ checks.append(_secret_exposure_check(config))
131
+ checks.append(
132
+ Check(
133
+ "ai_contract",
134
+ "ok",
135
+ f"AI service contract: {config.service_kind}; provider metadata: {config.model_provider}; telemetry: {config.telemetry}.",
136
+ )
137
+ )
138
+
139
+ if config.target == "local":
140
+ checks.append(Check("target", "ok", "Local target selected; no remote credentials are required."))
141
+ else:
142
+ checks.append(
143
+ Check(
144
+ "target",
145
+ "warning",
146
+ f"Target '{config.target}' is declared but no remote adapter is installed in this release.",
147
+ )
148
+ )
149
+ return DoctorReport(tuple(checks), config)
@@ -0,0 +1,50 @@
1
+ """Portable release-evidence generation for AI services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ from typing import Any
8
+
9
+ from dossierdock.doctor import DoctorReport
10
+
11
+
12
+ DOSSIER_SCHEMA_VERSION = "1.0"
13
+
14
+
15
+ def build_dossier(report: DoctorReport) -> dict[str, Any]:
16
+ """Build a shareable deployment dossier without reading or exposing secrets."""
17
+ if report.config is None:
18
+ return {
19
+ "schema_version": DOSSIER_SCHEMA_VERSION,
20
+ "generated_at": datetime.now(timezone.utc).isoformat(),
21
+ "release_readiness": "blocked",
22
+ "blockers": [check.as_dict() for check in report.checks if check.status == "error"],
23
+ "review_items": [check.as_dict() for check in report.checks if check.status == "warning"],
24
+ }
25
+
26
+ config = report.config
27
+ digest = hashlib.sha256(config.config_path.read_bytes()).hexdigest()
28
+ blockers = [check.as_dict() for check in report.checks if check.status == "error"]
29
+ review_items = [check.as_dict() for check in report.checks if check.status == "warning"]
30
+ return {
31
+ "schema_version": DOSSIER_SCHEMA_VERSION,
32
+ "generated_at": datetime.now(timezone.utc).isoformat(),
33
+ "release_readiness": "ready" if not blockers and not review_items else "review" if not blockers else "blocked",
34
+ "application": {
35
+ "name": config.name,
36
+ "image": config.image,
37
+ "environment": config.environment,
38
+ "target": config.target,
39
+ "healthcheck": f"http://localhost:{config.port}{config.healthcheck}",
40
+ },
41
+ "ai_service_contract": {
42
+ "service_kind": config.service_kind,
43
+ "model_provider": config.model_provider,
44
+ "telemetry": config.telemetry,
45
+ },
46
+ "manifest_sha256": digest,
47
+ "blockers": blockers,
48
+ "review_items": review_items,
49
+ "checks": [check.as_dict() for check in report.checks],
50
+ }
@@ -0,0 +1,43 @@
1
+ """Versioned templates for generated CI workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ GITHUB_ACTIONS_WORKFLOW = """name: DossierDock CI
7
+
8
+ on:
9
+ push:
10
+ branches: [main]
11
+ pull_request:
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ verify:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: \"3.11\"
24
+ - name: Install project and DossierDock
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ python -m pip install dossierdock
28
+ if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi
29
+ - name: Run project tests
30
+ run: |
31
+ if [ -d tests ]; then python -m unittest discover -s tests -v; fi
32
+ - name: Validate container delivery inputs
33
+ run: dossierdock doctor
34
+ - name: Create release-evidence dossier
35
+ run: dossierdock dossier --force
36
+ - name: Preserve release evidence
37
+ uses: actions/upload-artifact@v4
38
+ with:
39
+ name: dossierdock-release-dossier
40
+ path: .dossierdock/release-dossier.json
41
+ - name: Build immutable container image
42
+ run: docker build --tag ${{ github.repository }}:${{ github.sha }} .
43
+ """
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: dossierdock
3
+ Version: 0.1.0
4
+ Summary: Release-readiness evidence and CI workflow generation for containerized AI services.
5
+ Author: Shashank Singh
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Shashank Singh
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/shasanksingh/dossierdock
29
+ Project-URL: Issues, https://github.com/shasanksingh/dossierdock/issues
30
+ Keywords: ai,containers,deployment,docker,ci-cd,mlops,release-evidence,security
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3 :: Only
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Software Development :: Build Tools
42
+ Requires-Python: >=3.11
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Provides-Extra: dev
46
+ Requires-Dist: build>=1.2; extra == "dev"
47
+ Requires-Dist: twine>=5.1; extra == "dev"
48
+ Dynamic: license-file
49
+
50
+ # DossierDock
51
+
52
+ `dossierdock` is a provider-neutral **release-evidence CLI for containerized AI services**. It does more than produce a build command: it checks the service contract, image hardening signals, health-route evidence, and local-secret exposure, then writes a portable release dossier for a pull request, CI artifact, or operational handover.
53
+
54
+ The first release deliberately does **not** own cloud credentials or send a deployment anywhere. That makes it a safe foundation for teams that want a clear, reviewable path from an AI service to a container build and CI pipeline. Remote targets (SSH, AWS, and other providers) can be added as opt-in adapters rather than built into every command.
55
+
56
+ ## Why it is different
57
+
58
+ Most deployment tools build or deploy an image. DossierDock focuses on answering: **“What evidence says this AI service is ready to release?”**
59
+
60
+ It produces a small, provider-neutral `release-dossier.json` containing only safe metadata: the service contract, health endpoint, manifest hash, blockers, review items, and checks performed. No API key, connection string, or secret value is collected or written to the dossier.
61
+
62
+ This makes the tool useful before choosing AWS, Docker, Kubernetes, a VM, or another delivery target.
63
+
64
+ ## What it does today
65
+
66
+ - Creates a portable `dossierdock.toml` manifest.
67
+ - Checks the Dockerfile, health endpoint, AI-service metadata, and deployment metadata before CI runs.
68
+ - Reviews root-container execution, missing Docker health checks, and common credential files that are not ignored.
69
+ - Looks for evidence that the declared HTTP health route exists in application source.
70
+ - Writes a portable release dossier suitable for CI artifacts and release reviews.
71
+ - Prints a deterministic build and release plan as text or JSON.
72
+ - Writes a GitHub Actions workflow that tests the project and builds an immutable container image.
73
+ - Uses only the Python standard library at runtime.
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ python -m pip install -e .
79
+ dossierdock init --path .
80
+ dossierdock doctor
81
+ dossierdock plan
82
+ dossierdock dossier
83
+ dossierdock workflow github --output .github/workflows/dossierdock.yml
84
+ ```
85
+
86
+ Add a health endpoint to your API, then build it locally:
87
+
88
+ ```bash
89
+ docker build -t my-ai-service:dev .
90
+ docker run --rm -p 8000:8000 my-ai-service:dev
91
+ ```
92
+
93
+ ## Manifest
94
+
95
+ ```toml
96
+ [app]
97
+ name = "my-ai-service"
98
+ dockerfile = "Dockerfile"
99
+ context = "."
100
+ image = "my-ai-service"
101
+ healthcheck = "/health"
102
+ port = 8000
103
+
104
+ [deployment]
105
+ target = "local"
106
+ environment = "development"
107
+
108
+ [ai]
109
+ service_kind = "ai-api"
110
+ model_provider = "external"
111
+ telemetry = "open-telemetry"
112
+ ```
113
+
114
+ The manifest contains no credentials. Keep provider credentials in the CI platform's secret store and add a target adapter only when you are ready to deploy.
115
+
116
+ ## Commands
117
+
118
+ | Command | Purpose |
119
+ | --- | --- |
120
+ | `dossierdock init` | Create a starter manifest. |
121
+ | `dossierdock doctor` | Report release-readiness issues and checks. |
122
+ | `dossierdock plan` | Show the container build and health-check plan. |
123
+ | `dossierdock dossier` | Write reviewable, secret-free release evidence as JSON. |
124
+ | `dossierdock workflow github` | Create a GitHub Actions CI workflow. |
125
+
126
+ ## Release dossier
127
+
128
+ ```bash
129
+ dossierdock dossier --output .dossierdock/release-dossier.json
130
+ ```
131
+
132
+ The dossier records the application, declared AI-service contract, health URL, SHA-256 fingerprint of the manifest, blockers, and review items. Generated GitHub Actions workflows upload it as a build artifact so the release evidence stays with the exact CI run that built the image.
133
+
134
+ ## Project layout
135
+
136
+ ```text
137
+ src/dossierdock/ CLI, config loader, checks, workflow templates
138
+ tests/ Standard-library unit tests
139
+ examples/ A deployable manifest example
140
+ docs/ Architecture and contributor notes
141
+ ```
142
+
143
+ ## Roadmap
144
+
145
+ 1. Docker build and local smoke-test adapters.
146
+ 2. OpenTelemetry-compatible deployment annotations and release events.
147
+ 3. SSH release adapter with rollback and health verification.
148
+ 4. Separate AWS deployment adapter and learning guide.
149
+
150
+ ## Contributing
151
+
152
+ See [CONTRIBUTING.md](CONTRIBUTING.md). This project is released under the [MIT License](LICENSE).
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dossierdock/__init__.py
5
+ src/dossierdock/__main__.py
6
+ src/dossierdock/cli.py
7
+ src/dossierdock/config.py
8
+ src/dossierdock/doctor.py
9
+ src/dossierdock/evidence.py
10
+ src/dossierdock/workflow.py
11
+ src/dossierdock.egg-info/PKG-INFO
12
+ src/dossierdock.egg-info/SOURCES.txt
13
+ src/dossierdock.egg-info/dependency_links.txt
14
+ src/dossierdock.egg-info/entry_points.txt
15
+ src/dossierdock.egg-info/requires.txt
16
+ src/dossierdock.egg-info/top_level.txt
17
+ tests/test_cli.py
18
+ tests/test_config.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dossierdock = dossierdock.cli:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ build>=1.2
4
+ twine>=5.1
@@ -0,0 +1 @@
1
+ dossierdock
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import redirect_stderr, redirect_stdout
4
+ from io import StringIO
5
+ import json
6
+ from pathlib import Path
7
+ import tempfile
8
+ import unittest
9
+
10
+ from dossierdock.cli import main
11
+ from dossierdock.doctor import run_doctor
12
+
13
+
14
+ class CliTests(unittest.TestCase):
15
+ def _call(self, arguments: list[str]) -> tuple[int, str, str]:
16
+ output, errors = StringIO(), StringIO()
17
+ with redirect_stdout(output), redirect_stderr(errors):
18
+ code = main(arguments)
19
+ return code, output.getvalue(), errors.getvalue()
20
+
21
+ def test_init_creates_manifest_without_overwriting(self) -> None:
22
+ with tempfile.TemporaryDirectory() as temporary:
23
+ root = Path(temporary)
24
+ code, output, _ = self._call(["init", "--path", str(root)])
25
+ self.assertEqual(code, 0)
26
+ self.assertIn("Created", output)
27
+ self.assertTrue((root / "dossierdock.toml").is_file())
28
+
29
+ code, _, errors = self._call(["init", "--path", str(root)])
30
+ self.assertEqual(code, 2)
31
+ self.assertIn("Refusing to overwrite", errors)
32
+
33
+ def test_doctor_and_plan_accept_valid_container_inputs(self) -> None:
34
+ with tempfile.TemporaryDirectory() as temporary:
35
+ root = Path(temporary)
36
+ self._call(["init", "--path", str(root)])
37
+ (root / "Dockerfile").write_text("FROM python:3.11-slim\n", encoding="utf-8")
38
+ report = run_doctor(root / "dossierdock.toml")
39
+ self.assertTrue(report.healthy)
40
+
41
+ code, output, _ = self._call(["plan", "--config", str(root / "dossierdock.toml")])
42
+ self.assertEqual(code, 0)
43
+ self.assertIn("docker build", output)
44
+ self.assertIn("http://localhost:8000/health", output)
45
+
46
+ def test_workflow_generation_is_explicit_and_repeat_safe(self) -> None:
47
+ with tempfile.TemporaryDirectory() as temporary:
48
+ destination = Path(temporary) / ".github" / "workflows" / "dossierdock.yml"
49
+ code, _, _ = self._call(["workflow", "github", "--output", str(destination)])
50
+ self.assertEqual(code, 0)
51
+ content = destination.read_text(encoding="utf-8")
52
+ self.assertIn("docker build", content)
53
+ self.assertIn("dossierdock doctor", content)
54
+
55
+ code, _, errors = self._call(["workflow", "github", "--output", str(destination)])
56
+ self.assertEqual(code, 2)
57
+ self.assertIn("Refusing to overwrite", errors)
58
+
59
+ def test_dossier_captures_release_evidence_without_secret_values(self) -> None:
60
+ with tempfile.TemporaryDirectory() as temporary:
61
+ root = Path(temporary)
62
+ self._call(["init", "--path", str(root)])
63
+ (root / "Dockerfile").write_text(
64
+ "FROM python:3.12-slim\nUSER 10001\nHEALTHCHECK CMD python -c \"print('ok')\"\n",
65
+ encoding="utf-8",
66
+ )
67
+ (root / ".dockerignore").write_text(".env\n", encoding="utf-8")
68
+ (root / "app.py").write_text('@app.get("/health")\ndef health(): pass\n', encoding="utf-8")
69
+ destination = root / "evidence.json"
70
+
71
+ code, output, _ = self._call(
72
+ ["dossier", "--config", str(root / "dossierdock.toml"), "--output", str(destination)]
73
+ )
74
+
75
+ self.assertEqual(code, 0)
76
+ self.assertIn("Created release dossier", output)
77
+ dossier = json.loads(destination.read_text(encoding="utf-8"))
78
+ self.assertEqual(dossier["release_readiness"], "ready")
79
+ self.assertEqual(dossier["ai_service_contract"]["service_kind"], "ai-api")
80
+ self.assertNotIn("do-not-copy-this", json.dumps(dossier))
81
+
82
+ def test_unignored_env_file_blocks_release(self) -> None:
83
+ with tempfile.TemporaryDirectory() as temporary:
84
+ root = Path(temporary)
85
+ self._call(["init", "--path", str(root)])
86
+ (root / "Dockerfile").write_text("FROM python:3.12-slim\n", encoding="utf-8")
87
+ (root / ".env").write_text("SERVICE_TOKEN=do-not-copy-this\n", encoding="utf-8")
88
+ report = run_doctor(root / "dossierdock.toml")
89
+ self.assertFalse(report.healthy)
90
+ exposure = next(check for check in report.checks if check.name == "secret_exposure")
91
+ self.assertEqual(exposure.status, "error")
92
+ self.assertNotIn("do-not-copy-this", exposure.message)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import tempfile
5
+ import unittest
6
+
7
+ from dossierdock.config import ConfigError, load_config
8
+
9
+
10
+ VALID_MANIFEST = """[app]
11
+ name = "analytics-api"
12
+ dockerfile = "Dockerfile"
13
+ context = "."
14
+ image = "analytics-api"
15
+ healthcheck = "/health"
16
+ port = 8000
17
+
18
+ [deployment]
19
+ target = "local"
20
+ environment = "test"
21
+ """
22
+
23
+
24
+ class ConfigTests(unittest.TestCase):
25
+ def _manifest(self, text: str) -> Path:
26
+ temporary = tempfile.TemporaryDirectory()
27
+ self.addCleanup(temporary.cleanup)
28
+ path = Path(temporary.name) / "dossierdock.toml"
29
+ path.write_text(text, encoding="utf-8")
30
+ return path
31
+
32
+ def test_loads_valid_manifest(self) -> None:
33
+ config = load_config(self._manifest(VALID_MANIFEST))
34
+ self.assertEqual(config.name, "analytics-api")
35
+ self.assertEqual(config.port, 8000)
36
+ self.assertEqual(config.healthcheck, "/health")
37
+
38
+ def test_rejects_healthcheck_without_leading_slash(self) -> None:
39
+ manifest = self._manifest(VALID_MANIFEST.replace('healthcheck = "/health"', 'healthcheck = "health"'))
40
+ with self.assertRaisesRegex(ConfigError, "must start"):
41
+ load_config(manifest)
42
+
43
+ def test_rejects_invalid_port(self) -> None:
44
+ manifest = self._manifest(VALID_MANIFEST.replace("port = 8000", "port = 0"))
45
+ with self.assertRaisesRegex(ConfigError, "between 1 and 65535"):
46
+ load_config(manifest)