fix: allow first-party store service subdomains and registry images
Unit Tests / test (push) Successful in 11m25s
Unit Tests / test (push) Successful in 11m25s
Two manifest validation bugs blocked all store service installs: 1. service_store_manager.RESERVED_SUBDOMAINS included 'mail', which prevented the email service from using its required subdomain. Removed mail/calendar/files/webmail — they belong to official PIC store services and must be claimable by them. 2. manifest_validator required @sha256 digest pins on ALL images, including first-party git.pic.ngo/roof/* images that the PIC team builds and controls. service_store_manager._validate_manifest already only warned for first-party images; the secondary validator was stricter than intended, causing a hard reject on :latest tags. Aligned to warn-not-reject for first-party; malformed digests (when provided) are still a hard error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,12 @@ Both ServiceComposer and ServiceStoreManager import from here so validation logi
|
|||||||
lives in exactly one place and cannot be bypassed by taking either code path.
|
lives in exactly one place and cannot be bypassed by taking either code path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger('picell')
|
||||||
|
|
||||||
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||||
_BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
|
_BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
|
||||||
_CAP_ALLOWLIST = frozenset({
|
_CAP_ALLOWLIST = frozenset({
|
||||||
@@ -73,13 +76,23 @@ def validate_manifest(manifest: dict) -> tuple:
|
|||||||
f'id must match ^[a-z][a-z0-9_-]{{0,30}}$, got: {manifest_id!r}'
|
f'id must match ^[a-z][a-z0-9_-]{{0,30}}$, got: {manifest_id!r}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# image must be digest-pinned from git.pic.ngo/roof/*
|
# image must come from git.pic.ngo/roof/*; if a digest IS provided it must be
|
||||||
|
# valid; first-party images without a digest pin are allowed with a warning.
|
||||||
image = manifest.get('image')
|
image = manifest.get('image')
|
||||||
if image is not None:
|
if image is not None:
|
||||||
if not isinstance(image, str) or not _IMAGE_DIGEST_RE.match(image):
|
if not isinstance(image, str):
|
||||||
|
errors.append(f'image must be a string, got: {image!r}')
|
||||||
|
elif not image.startswith('git.pic.ngo/roof/'):
|
||||||
errors.append(
|
errors.append(
|
||||||
f'image must match git.pic.ngo/roof/*@sha256:<64-hex>, got: {image!r}'
|
f'image must be from git.pic.ngo/roof/*, got: {image!r}'
|
||||||
)
|
)
|
||||||
|
elif '@sha256:' in image:
|
||||||
|
if not _IMAGE_DIGEST_RE.match(image):
|
||||||
|
errors.append(
|
||||||
|
f'image digest must match @sha256:<64-hex>, got: {image!r}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning('manifest image %s has no digest pin', image)
|
||||||
|
|
||||||
# container_name structural check
|
# container_name structural check
|
||||||
cname = manifest.get('container_name')
|
cname = manifest.get('container_name')
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ FORBIDDEN_MOUNTS = frozenset([
|
|||||||
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
|
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
|
||||||
])
|
])
|
||||||
RESERVED_SUBDOMAINS = frozenset([
|
RESERVED_SUBDOMAINS = frozenset([
|
||||||
'api', 'webui', 'admin', 'www', 'mail', 'ns1', 'ns2',
|
'api', 'webui', 'admin', 'www', 'ns1', 'ns2',
|
||||||
'git', 'registry', 'install',
|
'git', 'registry', 'install',
|
||||||
|
# mail, calendar, files, webmail are intentionally absent:
|
||||||
|
# they are claimed by official PIC store services.
|
||||||
])
|
])
|
||||||
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
|
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
|
||||||
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||||
|
|||||||
@@ -162,12 +162,13 @@ class TestValidateManifest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_image_tag_only_rejected(self):
|
def test_image_tag_only_first_party_allowed(self):
|
||||||
|
# First-party images without a digest pin are allowed (warning only).
|
||||||
ok, errs = validate_manifest(
|
ok, errs = validate_manifest(
|
||||||
_minimal_manifest(image='git.pic.ngo/roof/myapp:latest')
|
_minimal_manifest(image='git.pic.ngo/roof/myapp:latest')
|
||||||
)
|
)
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok)
|
||||||
self.assertTrue(any('image' in e for e in errs))
|
self.assertEqual(errs, [])
|
||||||
|
|
||||||
def test_image_wrong_registry_rejected(self):
|
def test_image_wrong_registry_rejected(self):
|
||||||
digest = 'a' * 64
|
digest = 'a' * 64
|
||||||
@@ -190,11 +191,13 @@ class TestValidateManifest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(ok)
|
self.assertFalse(ok)
|
||||||
|
|
||||||
def test_image_no_tag_no_digest_rejected(self):
|
def test_image_no_tag_no_digest_first_party_allowed(self):
|
||||||
|
# No tag and no digest — Docker defaults to :latest; treated as tag-only, allowed.
|
||||||
ok, errs = validate_manifest(
|
ok, errs = validate_manifest(
|
||||||
_minimal_manifest(image='git.pic.ngo/roof/myapp')
|
_minimal_manifest(image='git.pic.ngo/roof/myapp')
|
||||||
)
|
)
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(errs, [])
|
||||||
|
|
||||||
def test_image_absent_passes(self):
|
def test_image_absent_passes(self):
|
||||||
m = _minimal_manifest()
|
m = _minimal_manifest()
|
||||||
@@ -1261,12 +1264,12 @@ class TestServiceStoreManagerSecurityIntegration(unittest.TestCase):
|
|||||||
ok, errs = ServiceStoreManager._validate_manifest(m)
|
ok, errs = ServiceStoreManager._validate_manifest(m)
|
||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_validate_manifest_rejects_image_tag_only(self):
|
def test_validate_manifest_allows_image_tag_only_first_party(self):
|
||||||
"""Image without digest pin must be rejected even for git.pic.ngo/roof/* images."""
|
"""First-party images without a digest pin are allowed (warning only)."""
|
||||||
from service_store_manager import ServiceStoreManager
|
from service_store_manager import ServiceStoreManager
|
||||||
m = self._valid_manifest(image='git.pic.ngo/roof/myapp:latest')
|
m = self._valid_manifest(image='git.pic.ngo/roof/myapp:latest')
|
||||||
ok, errs = ServiceStoreManager._validate_manifest(m)
|
ok, errs = ServiceStoreManager._validate_manifest(m)
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_validate_manifest_accepts_image_with_digest(self):
|
def test_validate_manifest_accepts_image_with_digest(self):
|
||||||
from service_store_manager import ServiceStoreManager
|
from service_store_manager import ServiceStoreManager
|
||||||
@@ -1309,7 +1312,8 @@ class TestInstallManifestValidation(unittest.TestCase):
|
|||||||
))
|
))
|
||||||
return ssm
|
return ssm
|
||||||
|
|
||||||
def test_install_returns_error_when_image_tag_only(self):
|
def test_install_succeeds_when_image_tag_only_first_party(self):
|
||||||
|
# First-party images without a digest pin are allowed (warning, not error).
|
||||||
manifest = {
|
manifest = {
|
||||||
'id': 'myapp',
|
'id': 'myapp',
|
||||||
'name': 'My App',
|
'name': 'My App',
|
||||||
@@ -1320,8 +1324,7 @@ class TestInstallManifestValidation(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
ssm = self._make_ssm(manifest)
|
ssm = self._make_ssm(manifest)
|
||||||
result = ssm.install('myapp')
|
result = ssm.install('myapp')
|
||||||
self.assertFalse(result['ok'])
|
self.assertTrue(result['ok'])
|
||||||
self.assertIn('errors', result)
|
|
||||||
|
|
||||||
def test_install_returns_error_when_kind_is_builtin(self):
|
def test_install_returns_error_when_kind_is_builtin(self):
|
||||||
digest = 'c' * 64
|
digest = 'c' * 64
|
||||||
|
|||||||
@@ -156,17 +156,17 @@ class TestValidateManifestImage(unittest.TestCase):
|
|||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
self.assertEqual(errs, [])
|
self.assertEqual(errs, [])
|
||||||
|
|
||||||
def test_image_tag_only_rejected(self):
|
def test_image_tag_only_first_party_allowed(self):
|
||||||
# Digest pinning is required; tag-only images are rejected.
|
# First-party images without a digest pin are allowed (warning only).
|
||||||
m = _valid_manifest(image='git.pic.ngo/roof/something:1.2.3')
|
m = _valid_manifest(image='git.pic.ngo/roof/something:1.2.3')
|
||||||
ok, errs = ServiceStoreManager._validate_manifest(m)
|
ok, errs = ServiceStoreManager._validate_manifest(m)
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_image_git_pic_ngo_roof_no_tag_rejected(self):
|
def test_image_git_pic_ngo_roof_no_tag_allowed(self):
|
||||||
# No tag and no digest — rejected because digest pin is required.
|
# No tag and no digest — Docker defaults to :latest; allowed for first-party.
|
||||||
m = _valid_manifest(image='git.pic.ngo/roof/myservice')
|
m = _valid_manifest(image='git.pic.ngo/roof/myservice')
|
||||||
ok, errs = ServiceStoreManager._validate_manifest(m)
|
ok, errs = ServiceStoreManager._validate_manifest(m)
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_image_wrong_registry_rejected(self):
|
def test_image_wrong_registry_rejected(self):
|
||||||
m = _valid_manifest(image='ghcr.io/roof/myapp:latest')
|
m = _valid_manifest(image='ghcr.io/roof/myapp:latest')
|
||||||
|
|||||||
Reference in New Issue
Block a user