Prepare scoped GitOps adoption and tested immutable image releases
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 7s
Build and Publish Container Image / build-and-push (push) Successful in 2m18s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
tegwick 2026-09-27 13:50:16 +02:00
parent 5d5ee84d70
commit c12a8fbcfb
12 changed files with 2585 additions and 731 deletions

View file

@ -2,6 +2,5 @@
__pycache__/
*.pyc
.git/
tests/
*.egg-info/
.env

View file

@ -16,6 +16,10 @@ on:
- "pyproject.toml"
- "uv.lock"
- "alembic.ini"
- "tests/**"
- "schemas/**"
- "k8s/**"
- ".dockerignore"
workflow_dispatch:
env:
@ -33,18 +37,22 @@ jobs:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
REF="${GITHUB_SHA:-main}"
SHORT="${REF:0:7}"
REF="${GITHUB_SHA:?commit required}"
test "${#REF}" -eq 40
mkdir -p buildctx "${HOME}/bin"
wget -qO /tmp/repo.tar.gz \
"https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
"https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${REF}.tar.gz"
tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar xz --strip-components=1 -C "${HOME}/bin" docker/docker
export PATH="${HOME}/bin:${PATH}"
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker build -t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" buildctx
docker push "${IMAGE}:latest"
docker push "${IMAGE}:main-${SHORT}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:main-${SHORT}"
# Check the exact source archive before publishing. Never update latest.
docker build --target test buildctx
docker build --label "org.opencontainers.image.revision=${REF}" -t "${IMAGE}:git-${REF}" buildctx
docker push "${IMAGE}:git-${REF}"
docker inspect --format '{{index .RepoDigests 0}}' "${IMAGE}:git-${REF}" > image-digest.txt
grep -Eq '^forgejo.coulomb.social/coulomb/activity-core@sha256:[a-f0-9]{64}$' image-digest.txt
cat image-digest.txt
# Deployment promotion is separate and consumes the digest, never the tag.

View file

@ -1,13 +1,23 @@
# Stage 1 — install Python deps
FROM python:3.12-slim AS builder
RUN pip install uv --no-cache-dir
FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS builder
RUN pip install uv==0.5.9 --no-cache-dir
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src/ ./src/
RUN uv sync --no-dev --frozen
# Isolated CI checks; development dependencies do not enter the runtime image.
FROM builder AS test
COPY tests/ ./tests/
COPY Dockerfile ./Dockerfile
COPY .forgejo/workflows/image.yaml ./.forgejo/workflows/image.yaml
COPY schemas/ ./schemas/
COPY k8s/ ./k8s/
COPY scripts/render_gitops.py scripts/check_gitops_promotion.py ./scripts/
RUN uv sync --frozen --extra dev && uv run --no-sync pytest -q -p no:cacheprovider tests/test_frontend_patterns.py tests/test_instruction_evaluation.py tests/test_admin_sync_api.py tests/test_gitops_release.py
# Stage 2 — runtime image
FROM python:3.12-slim AS runtime
FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS runtime
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src

View file

@ -0,0 +1,21 @@
{
"managed_resources": 9,
"server_dry_run": "passed",
"client_diff": "empty",
"spec_changes": 0,
"source": "live snapshot reconciled to canonical manifest; generated GitOps projection",
"excluded": [
"Jobs",
"Secrets",
"ESO custody",
"databases",
"Temporal",
"NATS",
"llm-connect",
"edge relay",
"storage",
"ingress"
],
"activation_authorization": "User requested implementation of ACTIVITY-WP-0041 on 2026-09-27",
"automation_enabled": false
}

66
docs/gitops-release.md Normal file
View file

@ -0,0 +1,66 @@
# Activity-core GitOps release lane
ACTIVITY-WP-0041 owns this lane; RPF-WP-0048 owns platform adoption. The founder
requested implementation on 2026-09-27. Adoption is authorized; the existing
24-hour healthy observation period and release-identity proof remain mandatory.
## Managed set and ownership
`k8s/gitops/kustomization.yaml` renders exactly nine resources: the API, worker
and event-router Deployments; API and worker-metrics Services; runtime config,
external definitions, report schemas and service-inventory ConfigMaps.
`scripts/render_gitops.py --check` verifies the generated projection against
`k8s/railiance/20-runtime.yaml`. Change that source and regenerate; after adoption,
never apply the mixed legacy directory directly. Migration/sync Jobs are deliberately
excluded. Sync definitions through the existing authenticated admin endpoint after
ConfigMap projection refresh; this is an explicit release verification step.
The baseline includes live backup wrapper mounts and the API's Temporal UI setting.
Dependencies stay with their existing owners: ESO/OpenBao secrets, verified backup
ConfigMap, storage, host-path contents, Temporal/NATS/databases, llm-connect, edge
relay, ingress/SSO and monitoring. ArgoCD must not prune or adopt them implicitly.
Namespace classification remains production-tier (platform target, no authoritative
rApp binding); MASON-WP-0006 is the mapping owner. Adoption does not invent a binding
or lower readiness requirements.
## Build and publication
The image workflow retrieves the full commit archive, builds the isolated test
stage, and publishes only `git-<full-sha>` plus its registry digest. Python's base
image digest, uv version and dependency lock are pinned. Deployments consume
`forgejo.coulomb.social/coulomb/activity-core@sha256:…`, independently for each
component. A commit tag is a lookup convenience, not an immutability guarantee.
Existing images are retained during metadata-only adoption; do not replace a
running component with newer code merely to complete adoption.
The pipeline uses its existing runner-held registry credentials. No credentials
are copied into Git or into the coding-agent session. Successful publication and
anonymous/cluster pull must be evidenced before the first digest promotion.
## Bounded routine authority — implementation boundary
Proposed executable scope `ACTIVITY-WP-0041-image-only-v1` is intentionally narrow:
only the three Deployment image fields and transition from Never to IfNotPresent
may change. No resource inventory, commands, mounts, environment, access, schema,
replicas, resources, migrations, definition behavior or budget changes are admitted.
Those changes require their owner review under the existing governance policy.
`scripts/check_gitops_promotion.py` refuses a non-digest image, widened diff,
missing independent review/checks, unadmitted identity, stale health evidence,
missing rollback revision, or less than 24 healthy hours. It is a **validator**,
not an authority issuer: evidence fields are not signatures. The release executor
must authenticate the CI/reviewer/health receipts and bind the exact candidate
revision before invoking it. A producer-supplied JSON file cannot grant access.
No unattended merge/deployment identity has been admitted by this change. Do not
mark this policy active or turn on automatic sync based only on these fixtures.
After identity proof and the observation period, enable only the activity-core
child's bounded promotion path; root-wide automation and destructive pruning stay
off. The existing root remains manually reconciled for unrelated applications.
A release must name the prior pinned revision before promotion, sync through
ArgoCD with pruning disabled, verify health and report-sink/schedule invariants,
and revert its source revision through ArgoCD on failure. Prove both successful
promotion and failed-health rollback before claiming unattended operation.
The 24-hour observation begins with the recorded successful adoption; a stateful
failure or unintended spec change invalidates that observation.

View file

@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- runtime.yaml

1517
k8s/gitops/runtime.yaml Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
"""Fail-closed admission check for a candidate image-only GitOps release.
This is a validator, not an authority issuer or a cluster/Git credential broker.
Evidence must be supplied by the separately admitted release identity.
"""
import argparse
import copy
from datetime import datetime, timedelta, timezone
import json
from pathlib import Path
import re
import yaml
IMAGE=re.compile(r'forgejo\.coulomb\.social/coulomb/activity-core@sha256:[0-9a-f]{64}')
SHA=re.compile(r'[0-9a-f]{40}')
DEPLOYS={'actcore-api','actcore-worker','actcore-event-router'}
def require(condition,message):
if not condition: raise ValueError(message)
def indexed(docs):
out={}
for d in docs:
require(isinstance(d,dict),'invalid resource')
key=(d['kind'],d['metadata']['name'])
require(key not in out and d['metadata'].get('namespace')=='activity-core','duplicate or foreign resource')
out[key]=copy.deepcopy(d)
return out
def validate(before,after,evidence,now=None):
now=now or datetime.now(timezone.utc)
require(evidence.get('schema_version')==1,'unknown evidence schema')
require(evidence.get('identity_admitted') is True,'release identity not admitted')
require(evidence.get('authority')=='ACTIVITY-WP-0041-image-only-v1','unknown authority')
require(evidence.get('review_result')=='pass' and evidence.get('checks')=='pass','checks/review not passed')
require(evidence.get('reviewer') and evidence.get('reviewer')!=evidence.get('producer'),'independent review required')
require(bool(SHA.fullmatch(evidence.get('candidate_commit',''))),'full candidate revision required')
require(bool(SHA.fullmatch(evidence.get('rollback_commit',''))),'rollback revision required')
require(evidence['candidate_commit']!=evidence['rollback_commit'],'rollback must name prior revision')
observed=datetime.fromisoformat(evidence['healthy_since'].replace('Z','+00:00'))
measured=datetime.fromisoformat(evidence['observed_at'].replace('Z','+00:00'))
require(observed.tzinfo is not None and measured.tzinfo is not None,'timestamps need timezone')
require(now-timedelta(minutes=5)<=measured<=now,'health evidence stale or future')
require(measured-observed>=timedelta(hours=24),'24-hour healthy observation period incomplete')
require(evidence.get('argo_synced') is True and evidence.get('argo_healthy') is True,'Argo not healthy/synced')
left,right=indexed(before),indexed(after)
require(left.keys()==right.keys(),'resource inventory change')
changed=[]
for key,a in left.items():
b=right[key]
if a==b: continue
require(key[0]=='Deployment' and key[1] in DEPLOYS,'only runtime images may change')
ac=a['spec']['template']['spec']['containers'];bc=b['spec']['template']['spec']['containers']
require(len(ac)==len(bc)==1,'container inventory change')
require(bool(IMAGE.fullmatch(bc[0].get('image',''))),'registry digest required')
require(bc[0].get('imagePullPolicy')=='IfNotPresent','pull policy must support registry recovery')
ac[0]['image']=bc[0]['image'];ac[0]['imagePullPolicy']=bc[0]['imagePullPolicy']
require(a==b,'non-image deployment change')
changed.append(key[1])
require(bool(changed),'no release change')
return {'admitted':True,'deployments':changed,'candidate_commit':evidence['candidate_commit'],'rollback_commit':evidence['rollback_commit']}
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('before',type=Path);p.add_argument('after',type=Path);p.add_argument('evidence',type=Path);a=p.parse_args()
try: print(json.dumps(validate(list(yaml.safe_load_all(a.before.read_text())),list(yaml.safe_load_all(a.after.read_text())),json.loads(a.evidence.read_text()))))
except (ValueError,KeyError,TypeError) as e: raise SystemExit(f'refused: {e}')

30
scripts/render_gitops.py Normal file
View file

@ -0,0 +1,30 @@
"""Render only the reviewed application resource set, excluding jobs and custody."""
import argparse
from pathlib import Path
import yaml
ROOT=Path(__file__).resolve().parents[1]
MANAGED={
'ConfigMap': ['actcore-runtime-config','actcore-external-activity-definitions','actcore-report-schemas','actcore-ops-service-inventory'],
'Service': ['actcore-api','actcore-worker-metrics'],
'Deployment': ['actcore-api','actcore-worker','actcore-event-router'],
}
class Dumper(yaml.SafeDumper): pass
def strings(d,v): return d.represent_scalar('tag:yaml.org,2002:str',v,style='|' if '\n' in v else None)
Dumper.add_representer(str,strings)
def render():
docs=list(yaml.safe_load_all((ROOT/'k8s/railiance/20-runtime.yaml').read_text()))
selected=[]
for kind,names in MANAGED.items():
for name in names:
matches=[d for d in docs if d and d['kind']==kind and d['metadata']['name']==name]
if len(matches)!=1: raise ValueError(f'expected exactly one {kind}/{name}')
d=matches[0]
if d['metadata'].get('namespace')!='activity-core': raise ValueError('namespace outside grant')
selected.append(d)
return '# Generated by scripts/render_gitops.py; do not edit directly.\n'+'---\n'.join(yaml.dump(d,Dumper=Dumper,sort_keys=False) for d in selected)
if __name__=='__main__':
parser=argparse.ArgumentParser(); parser.add_argument('--check',action='store_true'); args=parser.parse_args()
path=ROOT/'k8s/gitops/runtime.yaml'; text=render()
if args.check:
if path.read_text()!=text: raise SystemExit('GitOps projection stale; run scripts/render_gitops.py')
else: path.write_text(text)

View file

@ -0,0 +1,64 @@
"""Adoption and routine-promotion boundaries are checked without cluster access."""
import importlib.util
from pathlib import Path
import copy
import pytest
import yaml
ROOT=Path(__file__).resolve().parents[1]
def load(name):
spec=importlib.util.spec_from_file_location(name,ROOT/'scripts'/f'{name}.py')
mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod);return mod
def test_render_exact_reviewed_set_and_no_jobs_or_secrets():
render=load('render_gitops')
assert render.render()==(ROOT/'k8s/gitops/runtime.yaml').read_text()
docs=list(yaml.safe_load_all(render.render()))
assert len(docs)==9
assert {d['kind'] for d in docs}=={'Deployment','ConfigMap','Service'}
assert all(d['metadata']['namespace']=='activity-core' for d in docs)
worker=next(d for d in docs if d['metadata']['name']=='actcore-worker')
mounts=worker['spec']['template']['spec']['containers'][0]['volumeMounts']
assert any(m.get('subPath')=='entrypoint' and m['name']=='backup-verified' for m in mounts)
def test_image_workflow_publishes_commit_tag_and_digest():
text=(ROOT/'.forgejo/workflows/image.yaml').read_text()
assert ':latest' not in text and ':git-${REF}' in text
assert '${REF}.tar.gz' in text and '--target test' in text
assert 'RepoDigests' in text
def test_build_inputs_are_pinned():
text=(ROOT/'Dockerfile').read_text()
assert text.count('python:3.12-slim@sha256:')==2
assert 'uv==0.5.9' in text and '--frozen' in text
def promotion():
from datetime import datetime,timezone
before=list(yaml.safe_load_all((ROOT/'k8s/gitops/runtime.yaml').read_text()))
after=copy.deepcopy(before)
worker=next(d for d in after if d['metadata']['name']=='actcore-worker')
container=worker['spec']['template']['spec']['containers'][0]
container.update(image='forgejo.coulomb.social/coulomb/activity-core@sha256:'+'a'*64,imagePullPolicy='IfNotPresent')
evidence=dict(schema_version=1,identity_admitted=True,authority='ACTIVITY-WP-0041-image-only-v1',checks='pass',review_result='pass',reviewer='independent-ci',producer='build-ci',candidate_commit='a'*40,rollback_commit='b'*40,healthy_since='2026-09-26T00:00:00Z',observed_at='2026-09-27T01:00:00Z',argo_synced=True,argo_healthy=True)
return before,after,evidence,datetime(2026,9,27,1,tzinfo=timezone.utc)
def test_admits_only_digest_release_after_soak():
assert load('check_gitops_promotion').validate(*promotion())['deployments']==['actcore-worker']
@pytest.mark.parametrize('key,value',[('identity_admitted',False),('checks','fail'),('reviewer','build-ci'),('healthy_since','2026-09-27T00:00:00Z'),('observed_at','2026-09-26T01:00:00Z'),('argo_healthy',False),('rollback_commit','not-a-sha')])
def test_refuses_missing_release_guards(key,value):
before,after,evidence,now=promotion();evidence[key]=value
with pytest.raises(ValueError):load('check_gitops_promotion').validate(before,after,evidence,now)
@pytest.mark.parametrize('change',['command','resources','tag','inventory'])
def test_refuses_authority_expansion(change):
before,after,evidence,now=promotion()
worker=next(d for d in after if d['metadata']['name']=='actcore-worker')
c=worker['spec']['template']['spec']['containers'][0]
if change=='command':c['command']=['sh']
elif change=='resources':c['resources']={'requests':{'cpu':'2'}}
elif change=='tag':c['image']='forgejo.coulomb.social/coulomb/activity-core:latest'
else:after.pop()
with pytest.raises(ValueError):load('check_gitops_promotion').validate(before,after,evidence,now)

View file

@ -4,7 +4,7 @@ type: workplan
title: "Remove recurring deployment exceptions through governed GitOps adoption"
domain: infotech
repo: activity-core
status: ready
status: active
owner: codex
topic_slug: activity-core
created: "2026-09-27"
@ -22,7 +22,7 @@ e5dd02cb-b5b2-45c8-a600-748ea2ffd31b and does not change estate policy.
```task
id: ACTIVITY-WP-0041-T01
status: todo
status: progress
priority: high
state_hub_task_id: "fadce2e0-67c8-5dab-98cc-3a08d466a39f"
```
@ -44,7 +44,7 @@ server-dry-run the proposed managed set; no unintended spec change or pruning.
```task
id: ACTIVITY-WP-0041-T02
status: wait
status: progress
priority: high
state_hub_task_id: "af09865d-8cc9-5571-9c34-20ae679a1e4e"
```
@ -94,3 +94,18 @@ kubectl apply or a fresh founder exception. Failure recovers through the declare
rollback path. Human involvement is limited to the agreed monthly policy review.
GLAS-WP-0012/HFACT-WP-0001 readiness remains a separate prerequisite for automated
pattern editing; completing this plan does not imply that executor is admitted.
## Implementation authorization and scope — 2026-09-27
The founder explicitly requested implementation of these actionable tasks. This
supersedes the earlier wait for adoption authorization; it does not waive the
24-hour healthy observation period or invent an unattended release credential.
RPF-WP-0048 owns the scoped AppProject/child adoption. Nine runtime resources are
reconciled to live state with an empty client diff and successful server dry-run;
Jobs/custody/infrastructure are excluded. See docs/gitops-release.md.
Image CI now tests before publishing full-commit tags/digests with pinned build
inputs. Local container tests passed. Registry publication/readback must still be
observed. The image-only promotion validator has negative fixtures for widened
authority, stale/missing evidence, non-digest references and incomplete soak.
It is not a credential issuer or proof of an admitted unattended executor.