Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions control_plane/live_target_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ def _filter_runtime_environment_to_product_keys(
return {key: value for key, value in desired_env_map.items() if key in allowed_keys}


def _require_expected_runtime_secret_values(
*, desired_env_map: dict[str, str], runtime_secret_binding_keys: set[str]
) -> None:
missing_keys = sorted(key for key in runtime_secret_binding_keys if key not in desired_env_map)
if missing_keys:
raise LiveTargetRuntimeError(
"Expected managed runtime secret values are missing from the resolved "
f"Launchplane runtime environment: {', '.join(missing_keys)}.",
code="runtime_secret_values_missing",
)


def require_dokploy_target_definition(
*,
source_of_truth: control_plane_dokploy.DokploySourceOfTruth,
Expand Down Expand Up @@ -338,6 +350,10 @@ def apply_live_target_runtime_environment(
context_name=context_name,
instance_name=instance_name,
)
_require_expected_runtime_secret_values(
desired_env_map=desired_env_map,
runtime_secret_binding_keys=runtime_secret_binding_keys,
)
finally:
postgres_store.close()
if not desired_env_map:
Expand Down Expand Up @@ -389,9 +405,7 @@ def apply_live_target_runtime_environment(
context_name=context_name,
instance_name=instance_name,
require_policy=apply_changes,
required_binding_keys=tuple(
sorted(key for key in runtime_secret_binding_keys if key in desired_env_map)
),
required_binding_keys=tuple(sorted(runtime_secret_binding_keys)),
)
finally:
postgres_store.close()
Expand Down
6 changes: 6 additions & 0 deletions docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ title: Secrets
runtime managed-secret binding keys for the selected lane. Shared/global
runtime records can provide values, but they are not synced to an unrelated
product target unless that product profile declares the key.
- Odoo testing lanes declare only their stable compose runtime contract in
product onboarding seed material: `ODOO_DB_NAME`, `ODOO_DB_USER`,
`ODOO_DATA_VOLUME`, `ODOO_LOG_VOLUME`, `ODOO_DB_VOLUME`, and managed
testing-secret bindings for `ODOO_ADMIN_PASSWORD`, `ODOO_DB_PASSWORD`, and
`ODOO_MASTER_PASSWORD`. Odoo prod lanes stay undeclared until their production
runtime-secret ownership is validated explicitly.
- Gates fail closed when a required binding is missing, disabled, ambiguous,
unclassified, or scoped outside the target context/instance. A target with an
unknown environment class also fails closed.
Expand Down
154 changes: 154 additions & 0 deletions import-material/launchplane/seed-imports/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,83 @@
"deploy_timeout_seconds": 900
}
],
"runtime_environments": [
{
"scope": "instance",
"context": "cm",
"instance": "testing",
"env": {
"ODOO_DB_NAME": "cm_testing",
"ODOO_DB_USER": "odoo",
"ODOO_DATA_VOLUME": "cm_testing_odoo_data",
"ODOO_LOG_VOLUME": "cm_testing_odoo_logs",
"ODOO_DB_VOLUME": "cm_testing_odoo_db"
}
}
],
"secret_bindings": [
{
"binding_key": "ODOO_ADMIN_PASSWORD",
"context": "cm",
"instance": "testing"
},
{
"binding_key": "ODOO_DB_PASSWORD",
"context": "cm",
"instance": "testing"
},
{
"binding_key": "ODOO_MASTER_PASSWORD",
"context": "cm",
"instance": "testing"
}
],
"expected_config": {
"runtime_environment_keys": [
{
"key": "ODOO_DB_NAME",
"context": "cm",
"instance": "testing"
},
{
"key": "ODOO_DB_USER",
"context": "cm",
"instance": "testing"
},
{
"key": "ODOO_DATA_VOLUME",
"context": "cm",
"instance": "testing"
},
{
"key": "ODOO_LOG_VOLUME",
"context": "cm",
"instance": "testing"
},
{
"key": "ODOO_DB_VOLUME",
"context": "cm",
"instance": "testing"
}
],
"managed_secret_bindings": [
{
"binding_key": "ODOO_ADMIN_PASSWORD",
"context": "cm",
"instance": "testing"
},
{
"binding_key": "ODOO_DB_PASSWORD",
"context": "cm",
"instance": "testing"
},
{
"binding_key": "ODOO_MASTER_PASSWORD",
"context": "cm",
"instance": "testing"
}
]
},
"source_label": "import-material:odoo-cm-product-onboarding"
}
},
Expand Down Expand Up @@ -377,6 +454,83 @@
"deploy_timeout_seconds": 900
}
],
"runtime_environments": [
{
"scope": "instance",
"context": "opw",
"instance": "testing",
"env": {
"ODOO_DB_NAME": "opw_testing",
"ODOO_DB_USER": "odoo",
"ODOO_DATA_VOLUME": "opw_testing_odoo_data",
"ODOO_LOG_VOLUME": "opw_testing_odoo_logs",
"ODOO_DB_VOLUME": "opw_testing_odoo_db"
}
}
],
"secret_bindings": [
{
"binding_key": "ODOO_ADMIN_PASSWORD",
"context": "opw",
"instance": "testing"
},
{
"binding_key": "ODOO_DB_PASSWORD",
"context": "opw",
"instance": "testing"
},
{
"binding_key": "ODOO_MASTER_PASSWORD",
"context": "opw",
"instance": "testing"
}
],
"expected_config": {
"runtime_environment_keys": [
{
"key": "ODOO_DB_NAME",
"context": "opw",
"instance": "testing"
},
{
"key": "ODOO_DB_USER",
"context": "opw",
"instance": "testing"
},
{
"key": "ODOO_DATA_VOLUME",
"context": "opw",
"instance": "testing"
},
{
"key": "ODOO_LOG_VOLUME",
"context": "opw",
"instance": "testing"
},
{
"key": "ODOO_DB_VOLUME",
"context": "opw",
"instance": "testing"
}
],
"managed_secret_bindings": [
{
"binding_key": "ODOO_ADMIN_PASSWORD",
"context": "opw",
"instance": "testing"
},
{
"binding_key": "ODOO_DB_PASSWORD",
"context": "opw",
"instance": "testing"
},
{
"binding_key": "ODOO_MASTER_PASSWORD",
"context": "opw",
"instance": "testing"
}
]
},
"source_label": "import-material:odoo-opw-product-onboarding"
}
},
Expand Down
82 changes: 82 additions & 0 deletions tests/test_product_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@


CLI_MAIN = cast(Command, main)
ODOO_TESTING_RUNTIME_KEYS = (
"ODOO_DB_NAME",
"ODOO_DB_USER",
"ODOO_DATA_VOLUME",
"ODOO_LOG_VOLUME",
"ODOO_DB_VOLUME",
)
ODOO_TESTING_SECRET_KEYS = (
"ODOO_ADMIN_PASSWORD",
"ODOO_DB_PASSWORD",
"ODOO_MASTER_PASSWORD",
)


def _sqlite_database_url(database_path: Path) -> str:
Expand Down Expand Up @@ -162,6 +174,64 @@ def _seed_import_manifest(
return cast(dict[str, object], manifest_payload)


def _assert_odoo_testing_expected_runtime_contract(
test_case: unittest.TestCase,
*,
manifest: ProductOnboardingManifest,
context: str,
expected_database_name: str,
) -> None:
runtime_record = next(
record
for record in manifest.runtime_environments
if record.context == context and record.instance == "testing"
)
test_case.assertEqual(runtime_record.scope, "instance")
test_case.assertEqual(
runtime_record.env,
{
"ODOO_DB_NAME": expected_database_name,
"ODOO_DB_USER": "odoo",
"ODOO_DATA_VOLUME": f"{context}_testing_odoo_data",
"ODOO_LOG_VOLUME": f"{context}_testing_odoo_logs",
"ODOO_DB_VOLUME": f"{context}_testing_odoo_db",
},
)
testing_secret_bindings = [
(binding.context, binding.instance, binding.binding_key)
for binding in manifest.secret_bindings
if binding.context == context and binding.instance == "testing"
]
test_case.assertEqual(
testing_secret_bindings,
[(context, "testing", binding_key) for binding_key in ODOO_TESTING_SECRET_KEYS],
)
test_case.assertNotIn("prod", {record.instance for record in manifest.runtime_environments})
test_case.assertNotIn("prod", {binding.instance for binding in manifest.secret_bindings})
test_case.assertEqual(
[
(requirement.context, requirement.instance, requirement.key)
for requirement in manifest.expected_config.runtime_environment_keys
],
[(context, "testing", key) for key in ODOO_TESTING_RUNTIME_KEYS],
)
test_case.assertEqual(
[
(requirement.context, requirement.instance, requirement.binding_key)
for requirement in manifest.expected_config.managed_secret_bindings
],
[(context, "testing", binding_key) for binding_key in ODOO_TESTING_SECRET_KEYS],
)
test_case.assertNotIn(
"prod",
{requirement.instance for requirement in manifest.expected_config.runtime_environment_keys},
)
test_case.assertNotIn(
"prod",
{requirement.instance for requirement in manifest.expected_config.managed_secret_bindings},
)


class ProductOnboardingTests(unittest.TestCase):
def test_launchplane_seed_import_workflow_owns_seed_writes(self) -> None:
deploy_script = Path("scripts/deploy/ensure-authz-grants.sh").read_text(encoding="utf-8")
Expand Down Expand Up @@ -1440,6 +1510,12 @@ def test_seed_import_odoo_cm_onboarding_manifest_encodes_issue_backed_bootstrap_
("cm", "prod", "compose", "compose-cm-prod"),
],
)
_assert_odoo_testing_expected_runtime_contract(
self,
manifest=manifest,
context="cm",
expected_database_name="cm_testing",
)
self.assertEqual(manifest.source_label, "import-material:odoo-cm-product-onboarding")

def test_seed_import_odoo_cm_onboarding_manifest_requires_prod_target_id(self) -> None:
Expand Down Expand Up @@ -1486,6 +1562,12 @@ def test_seed_import_odoo_opw_onboarding_manifest_encodes_upstream_restore_polic
policies["prod"].expected_domains,
("opw-prod.shinycomputers.com",),
)
_assert_odoo_testing_expected_runtime_contract(
self,
manifest=manifest,
context="opw",
expected_database_name="opw_testing",
)

def test_apply_product_onboarding_manifest_writes_canonical_records(self) -> None:
with TemporaryDirectory() as temporary_directory_name:
Expand Down
Loading