diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-oci-export.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-oci-export.py new file mode 100644 index 00000000..8dc08404 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-oci-export.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Build-only public Forgejo owner recipe; no deployment or publication code.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time + +OWNER=Path(__file__).resolve().parents[1] +INPUTS=OWNER/'build/forgejo' +LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) + +def digest(path): + h=hashlib.sha256() + with Path(path).open('rb') as f: + for block in iter(lambda:f.read(1024*1024),b''):h.update(block) + return h.hexdigest() + +def save(path,data): + Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') + +def check(path,expected): + if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) + +def base_env(work): + return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', + 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', + 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], + 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', + 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), + 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', + 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', + 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), + 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', + 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', + 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} + +def sandbox(work,env): + cache=Path(env['GOMODCACHE']) + return ['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', + '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), + '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] + +def run(work,name,args,env=None,offline=False,cwd=None): + actual=sandbox(work,env)+args if offline else args + start=time.time() + with (work/(name+'.log')).open('w') as out: + proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) + save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) + code=proc.wait() + result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} + save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) + print(json.dumps(result),flush=True) + if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) + return result + +def checked_inputs(work): + for n,h in LOCK['host_tools'].items():check(n,h) + for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) + for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) + +def prepare(a,work): + if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') + for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: + if not getattr(a,n):raise ValueError('explicit '+n+' required') + check(getattr(a,n),LOCK[key]) + if not a.module_cache:raise ValueError('explicit public module cache required') + cache=Path(a.module_cache).resolve();assert cache.is_dir() + check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + for n,h in LOCK['host_tools'].items():check(n,h) + work.mkdir(parents=True);(work/'home').mkdir() + with tarfile.open(a.source_archive) as t: + # The official archive is digest-bound before any member is extracted. + parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') + folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() + for n in ['go_archive','node_archive']: + with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') + for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') + if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') + if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) + checked_inputs(work) + packages=json.loads((work/'source/package-lock.json').read_text())['packages'] + for name,package in packages.items(): + if not name:continue + if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): + raise ValueError('non-public/non-integrity-bound npm package: '+name) + print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) + +def normalize(folder): + epoch=LOCK['source_date_epoch'] + for root,dirs,files in os.walk(folder): + dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] + for n in files: + p=Path(root)/n + if not p.is_symlink():os.utime(p,(epoch,epoch)) + os.utime(root,(epoch,epoch)) + +def build(work): + checked_inputs(work);env=base_env(work) + run(work,'verify-go-modules',['go','mod','verify'],env,True) + run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) + normalize(work/'source') + version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] + run(work,'frontend',['make']+version+['frontend'],env,True) + for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) + run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) + checked_inputs(work) + save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) + +def verify(work): + checked_inputs(work);binary=work/'source/gitea' + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + target.mkdir() + # No source, asset tree, host /usr, libraries, configuration or HOME is + # exposed to the executable. A filesystem fallback cannot pass this proof. + bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', + '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] + for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', + 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', + 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): + bare+=['--setenv',k,v] + bare+=['--','/app/forgejo'] + outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} + run(work,'binary-version',bare+['--version'],outer) + run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) + records={} + for folder in ['public','options','templates']: + for f in sorted((work/'source'/folder).rglob('*')): + if not f.is_file():continue + n=str(f.relative_to(work/'source'));q=target/n + if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) + records[n]=digest(f) + assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records + assert 'public/assets/licenses.txt' in records + save(work/'embedded-files.json',records) + readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) + assert 'INTERP' not in readelf.stdout + save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), + 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, + 'no_source_assets_libraries_or_host_config_mounted':True, + 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', + 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) + print((work/'verification.json').read_text()) + +def container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + context=work/'container-context' + if context.exists():raise ValueError('container trial already exists; retain evidence') + context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') + os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) + (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') + os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) + config=work/'docker-config';config.mkdir(exist_ok=True) + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} + run(work,'container-build',['docker','buildx','build','--load','--platform',LOCK['platform'],'--pull=false', + '--network=none','--provenance=false','--sbom=false','--iidfile',str(work/'image-id'), + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(LOCK['source_date_epoch']),str(context)],env) + export_container(work) + +def export_container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + check(work/'container-context/gitea',verified['binary_sha256']) + assert json.loads((work/'container-build-result.json').read_text())['returncode']==0 + metadata=json.loads((work/'image-build-metadata.json').read_text()) + config_digest=(work/'image-id').read_text().strip() + assert config_digest==metadata['containerimage.config.digest'] + image=metadata['containerimage.digest'];assert image.startswith('sha256:') + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(work/'docker-config'),'DOCKER_HOST':'unix:///var/run/docker.sock'} + # Docker's containerd store addresses an untagged image by manifest digest; + # classic stores address it by config digest. Keep both identities explicit. + reference=None + for candidate in [image,config_digest]: + r=subprocess.run(['docker','image','inspect','--format','{{.Id}}',candidate],env=env,capture_output=True,text=True) + if r.returncode==0 and r.stdout.strip() in (image,config_digest):reference=candidate;break + if reference is None:raise RuntimeError('exact built local image cannot be resolved; never rebuild blindly') + archive_path=work/'image.tar' + if archive_path.exists():raise ValueError('retain existing export before explicit retry') + run(work,'container-save',['docker','image','save','--output',str(archive_path),reference],env) + with tarfile.open(work/'image.tar') as archive: + manifest=json.load(archive.extractfile('manifest.json'));assert len(manifest)==1 + item=manifest[0];config_bytes=archive.extractfile(item['Config']).read() + assert 'sha256:'+hashlib.sha256(config_bytes).hexdigest()==config_digest + config=json.loads(config_bytes) + base_bytes=(INPUTS/'runtime-base-config.json').read_bytes();assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] + original=json.loads(base_bytes) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + assert config['config']['User']=='1000:1000' + found=False + with tarfile.open(fileobj=archive.extractfile(item['Layers'][-1]),mode='r|*') as layer: + for member in layer: + if member.name.lstrip('./')=='app/gitea/gitea': + h=hashlib.sha256();stream=layer.extractfile(member) + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) + assert h.hexdigest()==verified['binary_sha256'];found=True + assert found + save(work/'container-result.json',{'local_image_id':image,'config_digest':config_digest,'local_export_reference':reference,'archive_sha256':digest(work/'image.tar'), + 'all_base_layers_and_rootless_runtime_config_preserved':True,'final_layer_binary_exact':True, + 'registry_publication':False,'deployment_tag':None,'host_or_secret_mounts':False,'privileged_containers':False}) + print((work/'container-result.json').read_text()) + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container','container-export']) + parser.add_argument('--workspace',required=True) + for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) + a=parser.parse_args();work=Path(a.workspace).resolve() + if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') + if a.phase=='prepare':return prepare(a,work) + assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') + if a.phase=='acquire-npm': + checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' + return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') + {'build':build,'verify':verify,'container':container,'container-export':export_container}[a.phase](work) + +if __name__=='__main__':main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recorded-normalization-refinement-driver.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recorded-normalization-refinement-driver.py new file mode 100644 index 00000000..50d67336 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recorded-normalization-refinement-driver.py @@ -0,0 +1,19 @@ +import importlib.util,pathlib,json,hashlib,shutil,sys +packet=pathlib.Path('/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1') +spec=importlib.util.spec_from_file_location('release',packet/'owner-source/tools/forgejo-release-build.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +which=sys.argv[1] +work=pathlib.Path('/tmp/apps-forgejo-release-repro-r1' if which=='second' else '/tmp/apps-forgejo-release-build-r1') +predecessor=m.digest(work/'source/gitea');shutil.copyfile(work/'source/gitea',work/'forgejo-before-directory-normalization') +m.checked_inputs(work) +for n in ['public','options','templates','modules/migration/schemas']:m.normalize(work/'source'/n) +args=['make','GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+m.LOCK['release_version'],'FORGEJO_VERSION='+m.LOCK['release_version'],'FORGEJO_VERSION_API='+m.LOCK['release_version'],'TAGS='+m.LOCK['tags'],'go-check','generate-backend','static-executable'] +if which=='second': + env=m.base_env(work);m.run(work,'normalized-embedded-and-static-backend',args,env,True) +else: + env=json.loads((work/'build-env.json').read_text());env['GOCACHE']='/tmp/apps-forgejo-release-repro-r1/gocache' + reviewer=pathlib.Path('/tmp/forgejo-independent-r1') + cmd=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(work),str(work),'--bind',env['GOCACHE'],env['GOCACHE'],'--ro-bind',str(reviewer/'go'),str(reviewer/'go'),'--ro-bind',str(reviewer/'gomodcache'),str(reviewer/'gomodcache'),'--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()]+args + m.run(work,'normalized-embedded-and-static-backend',cmd,{'PATH':'/usr/bin:/bin','HOME':str(work/'home')}) +m.checked_inputs(work) +m.save(work/'normalized-build.json',{'binary_sha256':m.digest(work/'source/gitea'),'predecessor_sha256':predecessor,'file_and_directory_times_normalized':True,'explicit_make_GOFLAGS':True,'source_locks_preserved':True,'refinement_same_trial':True,'owner_approval':False}) +print((work/'normalized-build.json').read_text()) diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-amd64-manifest.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-amd64-manifest.json new file mode 100644 index 00000000..434e2191 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-amd64-manifest.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b", + "size": 7360 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870", + "size": 3642247 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:ee6a63471a2d8c6f987ad8699af167dc045c893d6060441f6f7aa216edf48b5b", + "size": 16935733 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:6dea1fa90bf9ce85ca9119fea80966f08328f12d1a7f78e01f1ec22b7bdf71a7", + "size": 907 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:9308091ee87a5af192b263f4e48a2b74532fc6173959cf815880960595f35f6d", + "size": 162 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:b2912c13f9ccbba1aa1dde241cf7db994fb5622a110d875cf20f8b33246aebc8", + "size": 170 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:3448a9fe7124e7ce78e2ba1c320f6149ecec4e1daa7a043b88e9dd3b7681d5e3", + "size": 2349 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:07f673aaf066a1cec01ab6768992ee260e4794f693cb22585d2e7d7386387faf", + "size": 169 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:a3ee7de65d57741a38da7c9852b81ed9a57b848c68bc5b03d07b6c07a4802c20", + "size": 45351336 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:047a4c09ff1e32c905762257e4c9b58eef86fc7d126e44e45ced4d266612505e", + "size": 154 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:ac2d856a9d2af551ff928db4d466771d9de670c7affd3f22e20e2b4b9db5db6b", + "size": 7036678 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:b9fa35057fd93015c8ec114eb39686e071fdccce318be5eff55b2cae165a2ff0", + "size": 550 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", + "size": 32 + } + ] +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-base-config.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-base-config.json new file mode 100644 index 00000000..e2ad4cd2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-base-config.json @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-index.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-index.json new file mode 100644 index 00000000..05562200 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/runtime-public-index.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", + "size": 2563, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:5b8afd5c19392bf83b0bf2761eb03c214b98851e6460d99597bf133f81408522", + "size": 2563, + "platform": { + "architecture": "arm64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:476c51f625f2e42b604132cc1dc5756fd2391754ffacc93a4a330ff5da8ca213", + "size": 2563, + "platform": { + "architecture": "arm", + "os": "linux", + "variant": "v6" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:b7c7a4727334c8a16db692e17c4d9f1344dd08ad745d3047ce1e29e975b0dbb5", + "size": 566, + "annotations": { + "vnd.docker.reference.digest": "sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:e1709efc46fe4720bb26c37eea9ba21ebb02db826d0192a3c8f2dc717c116057", + "size": 566, + "annotations": { + "vnd.docker.reference.digest": "sha256:5b8afd5c19392bf83b0bf2761eb03c214b98851e6460d99597bf133f81408522", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:7d5556ea38cca9d296fba95aa43ccafe5d74c23252dad69369ae6f9d88fbd9e4", + "size": 566, + "annotations": { + "vnd.docker.reference.digest": "sha256:476c51f625f2e42b604132cc1dc5756fd2391754ffacc93a4a330ff5da8ca213", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/toolchains.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/toolchains.json new file mode 100644 index 00000000..edffb4a9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/toolchains.json @@ -0,0 +1,9 @@ +{ + "go": "go version go1.24.3 linux/amd64", + "node": "v22.15.0", + "gcc": "gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", + "make": "GNU Make 4.3", + "npm": "10.9.2", + "platform": "linux/amd64", + "empty_HOME_and_closed_environment": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification-refinement.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification-refinement.json new file mode 100644 index 00000000..af2b38bc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification-refinement.json @@ -0,0 +1,8 @@ +{ + "failed_phase": "embedded extraction harness", + "reason": "upstream CLI requires explicit file pattern; omitted glob", + "source_binary_unchanged": true, + "correction": "supply literal ** argv, with no shell expansion", + "failed_log_and_result_retained": true, + "same_trial": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification.json new file mode 100644 index 00000000..c069f058 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verification.json @@ -0,0 +1,13 @@ +{ + "all_public_options_templates_exact": true, + "architecture_approval": false, + "binary_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "embedded_file_manifest_sha256": "74a0ec278ab99ac86ee434d7620229dc4a70146efd51876a1e4738d2c9f4ffdb", + "embedded_files": 2234, + "native_admission": false, + "network": "bwrap --unshare-net", + "new_cli_flag_present": true, + "no_dynamic_interpreter": true, + "no_source_assets_libraries_or_host_config_mounted": true, + "source_locks_preserved": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verify-go-modules-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verify-go-modules-result.json new file mode 100644 index 00000000..a93c9631 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/verify-go-modules-result.json @@ -0,0 +1,10 @@ +{ + "argv": [ + "go", + "mod", + "verify" + ], + "phase": "verify-go-modules", + "returncode": 0, + "seconds": 11.88 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/Makefile b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/Makefile new file mode 100644 index 00000000..d08e729f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/Makefile @@ -0,0 +1,684 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +VERGABE_RELEASE ?= vergabe-teilnahme +VERGABE_NAMESPACE ?= vergabe-teilnahme +VERGABE_CHART ?= charts/vergabe-teilnahme +VERGABE_VALUES ?= helm/vergabe-teilnahme-values.yaml +VERGABE_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/vergabe-teilnahme +VERGABE_IMAGE_TAG ?= +VERGABE_INGRESS ?= manifests/vergabe-teilnahme-ingress.yaml + +VERGABE_DB_SECRET ?= vergabe-app-credentials +VERGABE_ENV_SECRET ?= vergabe-teilnahme-env +VERGABE_DB_USER ?= vergabe +VERGABE_DB_HOST ?= apps-pg-rw.databases +VERGABE_DB_PORT ?= 5432 +VERGABE_DB_NAME ?= vergabe_db + +COULOMB_SOCIAL_RELEASE ?= coulomb-social +COULOMB_SOCIAL_NAMESPACE ?= coulomb-social +COULOMB_SOCIAL_CHART ?= charts/coulomb-social +COULOMB_SOCIAL_VALUES ?= helm/coulomb-social-values.yaml +COULOMB_SOCIAL_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/coulomb-social +COULOMB_SOCIAL_INGRESS ?= manifests/coulomb-social-ingress.yaml +COULOMB_SOCIAL_ENV_SECRET ?= coulomb-social-env + +INTER_HUB_RELEASE ?= inter-hub +INTER_HUB_NAMESPACE ?= inter-hub +INTER_HUB_CHART ?= charts/inter-hub +INTER_HUB_VALUES ?= helm/inter-hub-values.yaml +INTER_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/inter-hub +INTER_HUB_IMAGE_TAG ?= +INTER_HUB_BASE_URL ?= https://hub.coulomb.social + +REUSE_RELEASE ?= reuse +REUSE_NAMESPACE ?= reuse +REUSE_CHART ?= charts/reuse-surface +REUSE_VALUES ?= helm/reuse-surface-values.yaml +REUSE_URL ?= https://reuse.coulomb.social +REUSE_CERTIFICATE ?= reuse-surface-tls + +POLICY_NEXUS_RAPP ?= ../rapp-policy-nexus +POLICY_NEXUS_BINDING ?= bindings/policy-nexus-production.json +POLICY_NEXUS_TARGET ?= railiance01 +POLICY_NEXUS_IMAGE_REPOSITORY ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)"))["image_repository"])') +POLICY_NEXUS_IMAGE_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("image_digest") or "")') +POLICY_NEXUS_PUBLICATION_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("publication_manifest_digest") or "")') +POLICY_NEXUS_SOURCE_INVENTORY_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_inventory_digest") or "")') +POLICY_NEXUS_SOURCE_SET_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_set_digest") or "")') +POLICY_NEXUS_REVISION ?= + +COULOMBCORE_KUBECONFIG ?= $(HOME)/.kube/config +RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-railiance01 +PRODUCTION_KUBECONFIG ?= $(COULOMBCORE_KUBECONFIG) +INTER_HUB_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +REUSE_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_RELEASE ?= forgejo +FORGEJO_NAMESPACE ?= forgejo +FORGEJO_CHART ?= gitea-charts/gitea +# Forgejo 11.x (Gitea 1.22) lacks `gitea config edit-ini`; chart 12.6+ requires Gitea 1.26+. +FORGEJO_CHART_VERSION ?= 12.5.0 +FORGEJO_VALUES ?= helm/forgejo-values.yaml +FORGEJO_REGISTRY_VALUES ?= helm/forgejo-registry-values.yaml +FORGEJO_SECRETS ?= helm/forgejo-secrets.sops.yaml +FORGEJO_SECRET_SCAN_TEMPLATE ?= manifests/forgejo-secret-scan-template.yaml +FORGEJO_INGRESS ?= manifests/forgejo-ingress.yaml +FORGEJO_SSH_NODEPORT ?= manifests/forgejo-ssh-nodeport.yaml +FORGEJO_BASE_URL ?= https://forgejo.coulomb.social +FORGEJO_DB_CLUSTER ?= forgejo-db +FORGEJO_DB_NAMESPACE ?= databases +FORGEJO_RUNNER_MANIFEST ?= manifests/forgejo-runner.yaml +FORGEJO_RUNNER_REGISTRATION_SECRET ?= helm/forgejo-runner-registration.sops.yaml +FORGEJO_MAILER_EXTERNAL_SECRET ?= manifests/forgejo-mailer-externalsecret.yaml +REUSE_RUNTIME_EXTERNAL_SECRET ?= manifests/reuse-surface-runtime-externalsecret.yaml +PLATFORM_REPO ?= $(HOME)/railiance-platform +ESO_CHART_VERSION ?= 0.16.1 +INTER_HUB_IMAGE_REF = $(INTER_HUB_IMAGE_REPOSITORY):$(INTER_HUB_IMAGE_TAG) +INTER_HUB_IMAGE_SET_ARG = $(if $(strip $(INTER_HUB_IMAGE_TAG)),--set image.tag=$(INTER_HUB_IMAGE_TAG),) + +CORE_HUB_RELEASE ?= core-hub +CORE_HUB_NAMESPACE ?= core-hub +CORE_HUB_CHART ?= charts/core-hub +CORE_HUB_VALUES ?= helm/core-hub-values.yaml +CORE_HUB_STAGING_NAMESPACE ?= core-hub-staging +CORE_HUB_STAGING_VALUES ?= helm/core-hub-staging-values.yaml +CORE_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/core-hub +CORE_HUB_IMAGE_TAG ?= +CORE_HUB_BASE_URL ?= https://hub.coulomb.social +CORE_HUB_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_IMAGE_REF = $(VERGABE_IMAGE_REPOSITORY):$(VERGABE_IMAGE_TAG) +VERGABE_IMAGE_SET_ARG = $(if $(strip $(VERGABE_IMAGE_TAG)),--set image.repository=$(VERGABE_IMAGE_REPOSITORY) --set image.tag=$(VERGABE_IMAGE_TAG),) +CORE_HUB_IMAGE_REF = $(CORE_HUB_IMAGE_REPOSITORY):$(CORE_HUB_IMAGE_TAG) +CORE_HUB_IMAGE_SET_ARG = $(if $(strip $(CORE_HUB_IMAGE_TAG)),--set image.tag=$(CORE_HUB_IMAGE_TAG),) + +SOPS_SENTINEL ?= $(FORGEJO_SECRETS) +DRY_RUN_CREATE_NAMESPACES ?= false + +##@ Operator checks + +check-tools: ## Check required operator tools and warn about optional diagnostics + tools/check-tools.sh + +check-sops: ## Verify the local SOPS age key can decrypt SOPS_SENTINEL + SOPS_SENTINEL="$(SOPS_SENTINEL)" tools/check-sops.sh + +k8s-server-dry-run: ## Server-side dry-run rendered Helm and committed manifests + DRY_RUN_CREATE_NAMESPACES=$(DRY_RUN_CREATE_NAMESPACES) tools/k8s-server-dry-run.sh + +apps-pg-status: ## Check the shared apps-pg cnpg cluster + @if kubectl cnpg status apps-pg -n databases >/dev/null 2>&1; then \ + kubectl cnpg status apps-pg -n databases; \ + else \ + echo "kubectl cnpg plugin not available; falling back to cnpg resources"; \ + kubectl get cluster apps-pg -n databases; \ + kubectl get pods -n databases -l cnpg.io/cluster=apps-pg; \ + fi + +check-railiance01-kubeconfig: ## Verify Railiance01 production kubeconfig exists + @test -r "$(RAILIANCE01_KUBECONFIG)" || { \ + echo "Missing Railiance01 kubeconfig: $(RAILIANCE01_KUBECONFIG)" >&2; \ + echo "Restore it with:" >&2; \ + echo " ssh tegwick@92.205.62.239 'sudo cat /etc/rancher/k3s/k3s.yaml' | sed 's|127.0.0.1|92.205.62.239|' > $(RAILIANCE01_KUBECONFIG)" >&2; \ + exit 1; \ + } + +check-coulombcore-kubeconfig: ## Verify CoulombCore kubeconfig and k3s API tunnel + @test -r "$(COULOMBCORE_KUBECONFIG)" || { \ + echo "Missing CoulombCore kubeconfig: $(COULOMBCORE_KUBECONFIG)" >&2; \ + echo "Ensure ops-bridge tunnel is up: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + @KUBECONFIG="$(COULOMBCORE_KUBECONFIG)" kubectl get --raw=/readyz >/dev/null 2>&1 || { \ + echo "CoulombCore API unreachable via $(COULOMBCORE_KUBECONFIG)." >&2; \ + echo "Run: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + +check-production-kubeconfig: check-coulombcore-kubeconfig ## Verify production app cluster API (CoulombCore) + +check-inter-hub-image-tag: ## Require an explicit inter-hub image tag for production deploys + @test -n "$(INTER_HUB_IMAGE_TAG)" || { \ + echo "Set INTER_HUB_IMAGE_TAG= for inter-hub production deploys." >&2; \ + echo "Example: INTER_HUB_IMAGE_TAG=91037a4 make inter-hub-deploy" >&2; \ + exit 1; \ + } + +check-inter-hub-image: check-inter-hub-image-tag ## Verify the inter-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(INTER_HUB_IMAGE_REF)" + +check-core-hub-image-tag: ## Require an explicit core-hub image tag for production deploys + @test -n "$(CORE_HUB_IMAGE_TAG)" || { \ + echo "Set CORE_HUB_IMAGE_TAG= for core-hub production deploys." >&2; \ + echo "Example: CORE_HUB_IMAGE_TAG=59bf1e4 make core-hub-deploy" >&2; \ + exit 1; \ + } + +check-core-hub-image: check-core-hub-image-tag ## Verify the core-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(CORE_HUB_IMAGE_REF)" + +cnpg-backup-status: check-production-kubeconfig ## Report Option A / ScheduledBackup coverage + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-status.sh + +cnpg-backup-offsite-secret-apply: check-production-kubeconfig ## Materialize databases/cnpg-backup-offsite from OpenBao (T02) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-backup-offsite-secret-dry-run: check-production-kubeconfig ## Dry-run Secret materialization (no apply) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" DRY_RUN=1 tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-option-a-apply: check-production-kubeconfig ## Apply Option A CronJobs + RBAC (production-touching) + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply -f manifests/cnpg-option-a-backup.yaml + +cnpg-option-a-dry-run: check-production-kubeconfig ## Server-side dry-run of Option A manifests + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply --dry-run=server -f manifests/cnpg-option-a-backup.yaml + +cnpg-logical-backup-dry-run: check-production-kubeconfig ## Logical dump + age for all four clusters (no upload) + chmod +x tools/cnpg-logical-backup.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/cnpg-logical-backup.sh + +cnpg-logical-backup: check-production-kubeconfig ## Logical dump + age + Nextcloud for all four clusters + chmod +x tools/cnpg-logical-backup.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/cnpg-logical-backup.sh + +##@ Option A multi-host (platform CLI / activity-core) + +cnpg-option-a-backup-dry-run: ## Platform multi-host Option A dump+age (JSON, no upload) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=1 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +cnpg-option-a-backup: ## Platform multi-host Option A dump+age+upload (JSON) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + @bao kv metadata get platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing or set RAILIANCE_BACKUP_NC_TOKEN." >&2; \ + exit 1; \ + } + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=0 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +apps-pg-backup-dry-run: check-production-kubeconfig ## Logical pg_dump + age encrypt for vergabe_db (no upload) + chmod +x tools/apps-pg-backup-dry-run.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/apps-pg-backup-dry-run.sh + +apps-pg-restore-drill: check-production-kubeconfig ## Isolated vergabe_db restore drill with row-count gate + chmod +x tools/apps-pg-restore-drill.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/apps-pg-restore-drill.sh + +apps-pg-backup: check-production-kubeconfig ## Logical pg_dump + age encrypt + Nextcloud upload for vergabe_db + chmod +x tools/apps-pg-backup-dry-run.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/apps-pg-backup-dry-run.sh + +##@ Vergabe Teilnahme + +check-vergabe-image-tag: ## Require an explicit vergabe-teilnahme image tag for production deploys + @test -n "$(VERGABE_IMAGE_TAG)" || { \ + echo "Set VERGABE_IMAGE_TAG= for vergabe production deploys." >&2; \ + echo "Example: VERGABE_IMAGE_TAG=1061f3e make vergabe-deploy" >&2; \ + exit 1; \ + } + +check-vergabe-image: check-vergabe-image-tag ## Verify the vergabe OCI image exists on Forgejo before deploy + tools/check-oci-image.sh "$(VERGABE_IMAGE_REF)" + +vergabe-dry-run: check-vergabe-image-tag ## helm template render (no apply) for inspection + helm template $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) + +vergabe-deploy: check-production-kubeconfig check-vergabe-image ## Deploy / upgrade vergabe-teilnahme Helm release + KUBECONFIG="$(VERGABE_KUBECONFIG)" helm upgrade --install $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) --create-namespace \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) --wait --timeout 3m + +vergabe-ingress-deploy: ## Apply the vergabe-teilnahme ingress (whywhynot.de) + kubectl apply -f $(VERGABE_INGRESS) + +vergabe-status: check-production-kubeconfig ## Show vergabe-teilnahme pod / svc / ingress / cert state + KUBECONFIG="$(VERGABE_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) --ignore-not-found + +vergabe-migrate: ## Run Django migrations against the live deployment + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py migrate --noinput + +vergabe-seed: ## Run the idempotent seed command + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py seed_dev + +vergabe-superuser: ## Open an interactive shell for createsuperuser + kubectl exec -it -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py createsuperuser + +vergabe-logs: ## Tail vergabe-teilnahme app logs + kubectl logs -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) -f --tail=50 + +vergabe-db-url-secret: ## Rebuild DATABASE_URL with a URL-encoded cnpg password + APP_NAMESPACE=$(VERGABE_NAMESPACE) \ + APP_ENV_SECRET=$(VERGABE_ENV_SECRET) \ + APP_DB_SECRET=$(VERGABE_DB_SECRET) \ + APP_DB_USER=$(VERGABE_DB_USER) \ + APP_DB_HOST=$(VERGABE_DB_HOST) \ + APP_DB_PORT=$(VERGABE_DB_PORT) \ + APP_DB_NAME=$(VERGABE_DB_NAME) \ + tools/build-database-url-secret.sh + + + +check-coulomb-social-image-tag: ## Require an explicit coulomb-social image tag + @test -n "$(COULOMB_SOCIAL_IMAGE_TAG)" || ( \ + echo "Set COULOMB_SOCIAL_IMAGE_TAG= for coulomb-social deploys." >&2; \ + echo "Example: COULOMB_SOCIAL_IMAGE_TAG=deadbeef make coulomb-social-deploy" >&2; \ + exit 1) + +coulomb-social-dry-run: check-coulomb-social-image-tag ## helm template render for coulomb-social + helm template $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) + +coulomb-social-deploy: check-production-kubeconfig check-coulomb-social-image-tag ## Deploy / upgrade coulomb-social + kubectl create namespace $(COULOMB_SOCIAL_NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - + helm upgrade --install $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) \ + --wait --timeout 5m + +coulomb-social-ingress-deploy: ## Apply coulomb-social ingress + kubectl apply -f $(COULOMB_SOCIAL_INGRESS) + +coulomb-social-status: check-production-kubeconfig ## Show coulomb-social pod/svc/ingress + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) get deploy,svc,ingress,pods + +coulomb-social-logs: ## Tail coulomb-social app logs + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) logs -l app.kubernetes.io/name=coulomb-social -f --tail=200 + + +coulomb-social-env-secret: ## Create/patch coulomb-social-env (SECRET_KEY, DATABASE_URL, USER_ENGINE_PROXY_SECRET) + @bash tools/create-coulomb-social-env-secret.sh $(COULOMB_SOCIAL_ENV_SECRET_ARGS) + +coulomb-social-env-secret-dry-run: ## Plan coulomb-social-env keys without applying + @bash tools/create-coulomb-social-env-secret.sh --dry-run $(COULOMB_SOCIAL_ENV_SECRET_ARGS) +##@ Inter-Hub + +inter-hub-render-baseline: ## Render checked-in inter-hub values for chart validation only + helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) + +inter-hub-dry-run: check-railiance01-kubeconfig check-inter-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) + +inter-hub-server-dry-run: check-railiance01-kubeconfig check-inter-hub-image ## Helm server dry-run inter-hub upgrade on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +inter-hub-deploy: check-railiance01-kubeconfig check-inter-hub-image ## Deploy / upgrade inter-hub Helm release on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +inter-hub-status: check-railiance01-kubeconfig ## Show inter-hub pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) --ignore-not-found + +inter-hub-release-info: check-railiance01-kubeconfig ## Show inter-hub Helm history, values, deployment, and pods + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm history $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm get values $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl describe deploy/$(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods -n $(INTER_HUB_NAMESPACE) -o wide + +inter-hub-smoke: ## Verify public inter-hub v2 route and OpenAPI surface after rollout + INTER_HUB_BASE_URL="$(INTER_HUB_BASE_URL)" tools/inter-hub-smoke.sh + +inter-hub-logs: check-railiance01-kubeconfig ## Tail inter-hub app logs from Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl logs -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) -f --tail=50 + +##@ Core Hub + +core-hub-helm-adopt: check-production-kubeconfig ## Annotate live Core Hub resources for Helm ownership + chmod +x tools/core-hub-helm-adopt.sh + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" CORE_HUB_NAMESPACE="$(CORE_HUB_NAMESPACE)" CORE_HUB_RELEASE="$(CORE_HUB_RELEASE)" \ + tools/core-hub-helm-adopt.sh + +core-hub-render-baseline: ## Render checked-in core-hub values for chart validation only + helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) + +core-hub-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-server-dry-run: check-production-kubeconfig check-core-hub-image ## Helm server dry-run core-hub upgrade on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +core-hub-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub Helm release on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations via a one-off Job + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) delete job $(CORE_HUB_RELEASE)-prod-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-prod-migrate --timeout=180s + +core-hub-status: check-production-kubeconfig ## Show core-hub pod / svc / ingress / cert state on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +core-hub-release-info: check-production-kubeconfig ## Show core-hub Helm history, values, deployment, and pods + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm history $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm get values $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl describe deploy/$(CORE_HUB_RELEASE)-api -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods -n $(CORE_HUB_NAMESPACE) -o wide + +core-hub-smoke: ## Verify public Core Hub health and v2 API surface after rollout + CORE_HUB_BASE_URL="$(CORE_HUB_BASE_URL)" tools/core-hub-smoke.sh + +core-hub-logs: check-production-kubeconfig ## Tail core-hub app logs from CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl logs -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub,app.kubernetes.io/component=api -f --tail=50 + +core-hub-staging-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-staging-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub staging release + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-staging-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) delete job $(CORE_HUB_RELEASE)-staging-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-staging-migrate --timeout=180s + +core-hub-staging-status: check-production-kubeconfig ## Show core-hub staging pod / svc state + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc -n $(CORE_HUB_STAGING_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +##@ Forgejo (forgejo.coulomb.social) + +eso-deploy: check-railiance01-kubeconfig ## Install External Secrets Operator on railiance01 + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add external-secrets https://charts.external-secrets.io --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install external-secrets external-secrets/external-secrets \ + --version $(ESO_CHART_VERSION) \ + --namespace external-secrets --create-namespace \ + --set installCRDs=true \ + --set serviceAccount.create=true \ + --set serviceAccount.name=external-secrets \ + --wait --timeout 5m + +forgejo-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (interactive) + tools/forgejo-openbao-eso-token-apply.sh + +forgejo-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-forgejo ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-forgejo.clustersecretstore.yaml" + +forgejo-mailer-es-deploy: check-railiance01-kubeconfig forgejo-openbao-store-deploy ## Apply Forgejo mailer ExternalSecret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_MAILER_EXTERNAL_SECRET) + +forgejo-mailer-es-status: check-railiance01-kubeconfig ## Show Forgejo mailer ExternalSecret sync status + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get externalsecret,secret -n $(FORGEJO_NAMESPACE) forgejo-mailer + +forgejo-dry-run: check-railiance01-kubeconfig ## helm template render for Forgejo + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm template $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) + +forgejo-server-dry-run: check-railiance01-kubeconfig ## Helm server dry-run Forgejo upgrade + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --dry-run=server --timeout 5m + +forgejo-secret-scan-template-deploy: check-railiance01-kubeconfig ## Install the immutable, non-secret receive-hook template + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply --server-side -f $(FORGEJO_SECRET_SCAN_TEMPLATE) + +forgejo-deploy: check-railiance01-kubeconfig forgejo-mailer-es-deploy forgejo-secret-scan-template-deploy ## Deploy / upgrade Forgejo on railiance01 + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-mailer -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-mailer in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Ensure ExternalSecret is Ready and OpenBao role external-secrets-forgejo is configured." >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add gitea-charts https://dl.gitea.com/charts/ --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --wait --timeout 10m + +forgejo-ingress-deploy: check-railiance01-kubeconfig ## Apply Forgejo HTTPS ingress + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_INGRESS) + +forgejo-ssh-nodeport-deploy: check-railiance01-kubeconfig ## Apply Forgejo SSH NodePort (30022) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_SSH_NODEPORT) + +forgejo-status: check-railiance01-kubeconfig ## Show Forgejo pods, svc, ingress, cert, database + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get pods,svc,ingress,certificate,pvc -n $(FORGEJO_NAMESPACE) --ignore-not-found + @if KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) >/dev/null 2>&1; then \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE); \ + else \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get cluster $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) --ignore-not-found; \ + fi + +forgejo-smoke: ## Verify Forgejo web and OCI registry challenge + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-smoke.sh + +forgejo-npm-smoke: ## T07 npm install smoke (@coulomb/forgejo-npm-probe) + tools/forgejo-npm-smoke.sh + +forgejo-verify: check-railiance01-kubeconfig ## T05 acceptance: smoke, TLS, DB, mailer ESO, SSH operator, SOPS sentinels + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-verify.sh + +forgejo-operator-bootstrap: ## Idempotent operator user + SSH key bootstrap (no secrets in Git) + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-operator-bootstrap.sh + +forgejo-breakglass-seal: check-railiance01-kubeconfig ## Founder: rotate break-glass admin password into OpenBao (interactive, production-touching) + FORGEJO_KUBECONFIG="$(FORGEJO_KUBECONFIG)" FORGEJO_NAMESPACE="$(FORGEJO_NAMESPACE)" tools/forgejo-breakglass-seal.sh + +forgejo-breakglass-verify: ## Founder: verify one break-glass sign-in and write value-free evidence + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-breakglass-verify.sh + +forgejo-runner-registration-sops-bootstrap: check-railiance01-kubeconfig ## Capture cluster runner token into SOPS (operator age key) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-runner-registration-sops-bootstrap.sh + +forgejo-secrets-check: ## Verify Forgejo SOPS sentinels exist and are encrypted + @test -f $(FORGEJO_SECRETS) && grep -q '^sops:' $(FORGEJO_SECRETS) || { echo "Missing encrypted $(FORGEJO_SECRETS)" >&2; exit 1; } + @if [ -f $(FORGEJO_RUNNER_REGISTRATION_SECRET) ]; then \ + grep -q '^sops:' $(FORGEJO_RUNNER_REGISTRATION_SECRET) || { echo "Runner registration not SOPS-encrypted" >&2; exit 1; }; \ + else \ + echo "WARN: $(FORGEJO_RUNNER_REGISTRATION_SECRET) missing — run forgejo-runner-registration-sops-bootstrap on operator host" >&2; \ + fi + @echo "ok: Forgejo SOPS sentinels" + +forgejo-logs: check-railiance01-kubeconfig ## Tail Forgejo application logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/instance=$(FORGEJO_RELEASE) -f --tail=50 + +forgejo-runner-registration-deploy: check-railiance01-kubeconfig check-sops ## Apply SOPS runner registration secret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f <(sops -d $(FORGEJO_RUNNER_REGISTRATION_SECRET)) + +forgejo-runner-deploy: check-railiance01-kubeconfig ## Deploy in-cluster Forgejo Actions runner (ADR-004) + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-runner-registration -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-runner-registration in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Apply helm/forgejo-runner-registration.sops.yaml or bootstrap once:" >&2; \ + echo " kubectl create secret generic forgejo-runner-registration -n forgejo --from-literal=token=" >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_RUNNER_MANIFEST) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl rollout status deployment/forgejo-runner -n $(FORGEJO_NAMESPACE) --timeout=5m + +forgejo-runner-status: check-railiance01-kubeconfig ## Show in-cluster runner pod and PVC state + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get deploy,pods,pvc -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/name=forgejo-runner --ignore-not-found + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner --tail=20 2>/dev/null || true + +forgejo-runner-logs: check-railiance01-kubeconfig ## Tail in-cluster runner logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner -f --tail=50 + +##@ reuse-surface (reuse.coulomb.social) + +reuse-dry-run: check-railiance01-kubeconfig ## helm template render (no apply) for reuse-surface + KUBECONFIG="$(REUSE_KUBECONFIG)" helm template $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) \ + -f $(REUSE_VALUES) + +reuse-deploy: check-railiance01-kubeconfig ## Deploy / upgrade reuse-surface Helm release on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" helm upgrade --install $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) --create-namespace \ + -f $(REUSE_VALUES) --wait --timeout 5m + +reuse-status: check-railiance01-kubeconfig ## Show reuse-surface pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get pods,svc,ingress,pvc,certificate -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) --ignore-not-found + +reuse-smoke: check-railiance01-kubeconfig ## Verify reuse public health, repo list, and TLS certificate readiness + curl -fsS "$(REUSE_URL)/health" + curl -fsS "$(REUSE_URL)/v1/repos" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl wait -n $(REUSE_NAMESPACE) --for=condition=Ready certificate/$(REUSE_CERTIFICATE) --timeout=30s + +reuse-logs: check-railiance01-kubeconfig ## Tail reuse-surface service logs from Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl logs -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) -f --tail=50 + +reuse-forgejo-webhook: check-railiance01-kubeconfig ## Idempotent org webhook on Forgejo -> reuse hub (REUSE-WP-0019-T03) + bash tools/reuse-forgejo-webhook.sh + +reuse-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (CCR-2026-0005) + OPENBAO_TOKEN_FILE="$(HOME)/.local/openbao/platform-admin.token" bash tools/reuse-openbao-eso-token-apply.sh + +reuse-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-reuse ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-reuse.clustersecretstore.yaml" + +reuse-runtime-es-deploy: check-railiance01-kubeconfig reuse-openbao-store-deploy ## Apply reuse-surface runtime ExternalSecret + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f $(REUSE_RUNTIME_EXTERNAL_SECRET) + +reuse-runtime-es-status: check-railiance01-kubeconfig ## Show reuse-surface runtime ExternalSecret sync status + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get externalsecret,secret -n $(REUSE_NAMESPACE) reuse-surface-runtime reuse-surface-env + +reuse-webhook-smoke: check-railiance01-kubeconfig ## Webhook + ESO + federated smoke (RAILIANCE-WP-0011-T04) + bash tools/reuse-webhook-smoke.sh + +##@ policy-nexus (policy.coulomb.social) + +policy-nexus-check-binding: ## Require a source-controlled, immutable production binding + @test -d "$(POLICY_NEXUS_RAPP)" || { echo "Missing sibling rApp repo: $(POLICY_NEXUS_RAPP)" >&2; exit 2; } + python3 tools/check-policy-nexus-binding.py "$(POLICY_NEXUS_BINDING)" + +policy-nexus-check-image: policy-nexus-check-binding ## Verify the bound OCI digest exists in Forgejo + tools/check-oci-image.sh "$(POLICY_NEXUS_IMAGE_REPOSITORY)@$(POLICY_NEXUS_IMAGE_DIGEST)" + +policy-nexus-render: policy-nexus-check-binding ## Render the rApp chart with the approved production binding + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" render \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-server-dry-run: policy-nexus-check-binding ## Validate the approved policy package against Railiance01 + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" server-dry-run TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-deploy: policy-nexus-check-binding ## Deploy the approved policy release through its rApp package + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" deploy TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-status: ## Show the policy-nexus Helm release state + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" status TARGET="$(POLICY_NEXUS_TARGET)" + +policy-nexus-smoke: policy-nexus-check-binding ## Verify public content, TLS, and both immutable release identities + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" verify-live TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-rollback: ## Roll back to a named previous Helm revision + @test -n "$(POLICY_NEXUS_REVISION)" || { echo "POLICY_NEXUS_REVISION is required" >&2; exit 2; } + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" rollback TARGET="$(POLICY_NEXUS_TARGET)" REVISION="$(POLICY_NEXUS_REVISION)" + +##@ Help + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} \ + /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + +.PHONY: forgejo-secret-scan-template-deploy + + +##@ Isolated Forgejo source release preparation (no deployment/publication) +FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release +FORGEJO_SOURCE_ARCHIVE ?= +FORGEJO_GO_ARCHIVE ?= +FORGEJO_NODE_ARCHIVE ?= +FORGEJO_BUILD_GOMODCACHE ?= +FORGEJO_BUILD_GOCACHE_SEED ?= +FORGEJO_BUILD_NPM_CACHE_SEED ?= + +.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container +forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace + /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") + +forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts + /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets + /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline + /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy + /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" + diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/tools/forgejo-release-build.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/tools/forgejo-release-build.py new file mode 100644 index 00000000..728faa58 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/owner-source/tools/forgejo-release-build.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build-only public Forgejo owner recipe; no deployment or publication code.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time +import math +import datetime + +OWNER=Path(__file__).resolve().parents[1] +INPUTS=OWNER/'build/forgejo' +LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) + +def digest(path): + h=hashlib.sha256() + with Path(path).open('rb') as f: + for block in iter(lambda:f.read(1024*1024),b''):h.update(block) + return h.hexdigest() + +def save(path,data): + Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') + +def check(path,expected): + if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) + +def base_env(work): + return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', + 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', + 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], + 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', + 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), + 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', + 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', + 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), + 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', + 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', + 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} + +def sandbox(work,env): + cache=Path(env['GOMODCACHE']) + return ['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', + '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), + '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] + +def run(work,name,args,env=None,offline=False,cwd=None): + actual=sandbox(work,env)+args if offline else args + start=time.time() + with (work/(name+'.log')).open('w') as out: + proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) + save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) + code=proc.wait() + result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} + save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) + print(json.dumps(result),flush=True) + if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) + return result + +def checked_inputs(work): + for n,h in LOCK['host_tools'].items():check(n,h) + for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) + for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) + +def prepare(a,work): + if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') + for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: + if not getattr(a,n):raise ValueError('explicit '+n+' required') + check(getattr(a,n),LOCK[key]) + if not a.module_cache:raise ValueError('explicit public module cache required') + cache=Path(a.module_cache).resolve();assert cache.is_dir() + check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + for n,h in LOCK['host_tools'].items():check(n,h) + work.mkdir(parents=True);(work/'home').mkdir() + with tarfile.open(a.source_archive) as t: + # The official archive is digest-bound before any member is extracted. + parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') + folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() + for n in ['go_archive','node_archive']: + with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') + for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') + if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') + if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) + checked_inputs(work) + packages=json.loads((work/'source/package-lock.json').read_text())['packages'] + for name,package in packages.items(): + if not name:continue + if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): + raise ValueError('non-public/non-integrity-bound npm package: '+name) + print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) + +def normalize(folder): + epoch=LOCK['source_date_epoch'] + for root,dirs,files in os.walk(folder): + dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] + for n in files: + p=Path(root)/n + if not p.is_symlink():os.utime(p,(epoch,epoch)) + os.utime(root,(epoch,epoch)) + +def build(work): + checked_inputs(work);env=base_env(work) + run(work,'verify-go-modules',['go','mod','verify'],env,True) + run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) + normalize(work/'source') + version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] + run(work,'frontend',['make']+version+['frontend'],env,True) + for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) + run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) + checked_inputs(work) + save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) + +def verify(work): + checked_inputs(work);binary=work/'source/gitea' + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + target.mkdir() + # No source, asset tree, host /usr, libraries, configuration or HOME is + # exposed to the executable. A filesystem fallback cannot pass this proof. + bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', + '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] + for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', + 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', + 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): + bare+=['--setenv',k,v] + bare+=['--','/app/forgejo'] + outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} + run(work,'binary-version',bare+['--version'],outer) + run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) + records={} + for folder in ['public','options','templates']: + for f in sorted((work/'source'/folder).rglob('*')): + if not f.is_file():continue + n=str(f.relative_to(work/'source'));q=target/n + if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) + records[n]=digest(f) + assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records + assert 'public/assets/licenses.txt' in records + save(work/'embedded-files.json',records) + readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) + assert 'INTERP' not in readelf.stdout + save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), + 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, + 'no_source_assets_libraries_or_host_config_mounted':True, + 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', + 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) + print((work/'verification.json').read_text()) + +def container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + context=work/'container-context' + if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') + archive_path=work/'image.oci.tar' + if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') + base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() + assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] + original=json.loads(base_bytes) + epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) + context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') + os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) + (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') + os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) + config=work/'docker-config';config.mkdir(exist_ok=True) + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} + # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. + # OCI output permits layer rewriting without the Docker-store unpack conflict. + # The epoch never predates the immutable base, preserving its exact layer prefix. + run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', + '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) + metadata=json.loads((work/'image-build-metadata.json').read_text()) + with tarfile.open(archive_path) as archive: + index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) + assert len(index['manifests'])==1 + descriptor=index['manifests'][0] + assert descriptor['platform']=={'architecture':'amd64','os':'linux'} + assert descriptor['digest']==metadata['containerimage.digest'] + def blob(d): + value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() + assert 'sha256:'+hashlib.sha256(value).hexdigest()==d + return value + manifest=json.loads(blob(descriptor['digest'])) + config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) + assert config_digest==metadata['containerimage.config.digest'] + for layer in manifest['layers']:blob(layer['digest']) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + assert config['architecture']=='amd64' and config['os']=='linux' + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + labels=dict(original['config'].get('Labels',{})) + labels.update({'org.opencontainers.image.version':LOCK['release_version'], + 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) + assert config['config'].get('Labels')==labels + assert config['config']['User']=='1000:1000' + expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') + assert config['created']==expected_created + found=False + last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] + with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: + for member in layer: + name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') + if name=='app/gitea/gitea': + h=hashlib.sha256();stream=layer.extractfile(member) + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) + assert h.hexdigest()==verified['binary_sha256'];found=True + assert found + save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, + 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), + 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, + 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, + 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, + 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) + print((work/'container-result.json').read_text()) + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) + parser.add_argument('--workspace',required=True) + for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) + a=parser.parse_args();work=Path(a.workspace).resolve() + if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') + if a.phase=='prepare':return prepare(a,work) + assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') + if a.phase=='acquire-npm': + checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' + return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') + {'build':build,'verify':verify,'container':container}[a.phase](work) + +if __name__=='__main__':main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/preimages/Makefile b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/preimages/Makefile new file mode 100644 index 00000000..da70a3ae --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/preimages/Makefile @@ -0,0 +1,657 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +VERGABE_RELEASE ?= vergabe-teilnahme +VERGABE_NAMESPACE ?= vergabe-teilnahme +VERGABE_CHART ?= charts/vergabe-teilnahme +VERGABE_VALUES ?= helm/vergabe-teilnahme-values.yaml +VERGABE_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/vergabe-teilnahme +VERGABE_IMAGE_TAG ?= +VERGABE_INGRESS ?= manifests/vergabe-teilnahme-ingress.yaml + +VERGABE_DB_SECRET ?= vergabe-app-credentials +VERGABE_ENV_SECRET ?= vergabe-teilnahme-env +VERGABE_DB_USER ?= vergabe +VERGABE_DB_HOST ?= apps-pg-rw.databases +VERGABE_DB_PORT ?= 5432 +VERGABE_DB_NAME ?= vergabe_db + +COULOMB_SOCIAL_RELEASE ?= coulomb-social +COULOMB_SOCIAL_NAMESPACE ?= coulomb-social +COULOMB_SOCIAL_CHART ?= charts/coulomb-social +COULOMB_SOCIAL_VALUES ?= helm/coulomb-social-values.yaml +COULOMB_SOCIAL_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/coulomb-social +COULOMB_SOCIAL_INGRESS ?= manifests/coulomb-social-ingress.yaml +COULOMB_SOCIAL_ENV_SECRET ?= coulomb-social-env + +INTER_HUB_RELEASE ?= inter-hub +INTER_HUB_NAMESPACE ?= inter-hub +INTER_HUB_CHART ?= charts/inter-hub +INTER_HUB_VALUES ?= helm/inter-hub-values.yaml +INTER_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/inter-hub +INTER_HUB_IMAGE_TAG ?= +INTER_HUB_BASE_URL ?= https://hub.coulomb.social + +REUSE_RELEASE ?= reuse +REUSE_NAMESPACE ?= reuse +REUSE_CHART ?= charts/reuse-surface +REUSE_VALUES ?= helm/reuse-surface-values.yaml +REUSE_URL ?= https://reuse.coulomb.social +REUSE_CERTIFICATE ?= reuse-surface-tls + +POLICY_NEXUS_RAPP ?= ../rapp-policy-nexus +POLICY_NEXUS_BINDING ?= bindings/policy-nexus-production.json +POLICY_NEXUS_TARGET ?= railiance01 +POLICY_NEXUS_IMAGE_REPOSITORY ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)"))["image_repository"])') +POLICY_NEXUS_IMAGE_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("image_digest") or "")') +POLICY_NEXUS_PUBLICATION_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("publication_manifest_digest") or "")') +POLICY_NEXUS_SOURCE_INVENTORY_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_inventory_digest") or "")') +POLICY_NEXUS_SOURCE_SET_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_set_digest") or "")') +POLICY_NEXUS_REVISION ?= + +COULOMBCORE_KUBECONFIG ?= $(HOME)/.kube/config +RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-railiance01 +PRODUCTION_KUBECONFIG ?= $(COULOMBCORE_KUBECONFIG) +INTER_HUB_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +REUSE_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_RELEASE ?= forgejo +FORGEJO_NAMESPACE ?= forgejo +FORGEJO_CHART ?= gitea-charts/gitea +# Forgejo 11.x (Gitea 1.22) lacks `gitea config edit-ini`; chart 12.6+ requires Gitea 1.26+. +FORGEJO_CHART_VERSION ?= 12.5.0 +FORGEJO_VALUES ?= helm/forgejo-values.yaml +FORGEJO_REGISTRY_VALUES ?= helm/forgejo-registry-values.yaml +FORGEJO_SECRETS ?= helm/forgejo-secrets.sops.yaml +FORGEJO_SECRET_SCAN_TEMPLATE ?= manifests/forgejo-secret-scan-template.yaml +FORGEJO_INGRESS ?= manifests/forgejo-ingress.yaml +FORGEJO_SSH_NODEPORT ?= manifests/forgejo-ssh-nodeport.yaml +FORGEJO_BASE_URL ?= https://forgejo.coulomb.social +FORGEJO_DB_CLUSTER ?= forgejo-db +FORGEJO_DB_NAMESPACE ?= databases +FORGEJO_RUNNER_MANIFEST ?= manifests/forgejo-runner.yaml +FORGEJO_RUNNER_REGISTRATION_SECRET ?= helm/forgejo-runner-registration.sops.yaml +FORGEJO_MAILER_EXTERNAL_SECRET ?= manifests/forgejo-mailer-externalsecret.yaml +REUSE_RUNTIME_EXTERNAL_SECRET ?= manifests/reuse-surface-runtime-externalsecret.yaml +PLATFORM_REPO ?= $(HOME)/railiance-platform +ESO_CHART_VERSION ?= 0.16.1 +INTER_HUB_IMAGE_REF = $(INTER_HUB_IMAGE_REPOSITORY):$(INTER_HUB_IMAGE_TAG) +INTER_HUB_IMAGE_SET_ARG = $(if $(strip $(INTER_HUB_IMAGE_TAG)),--set image.tag=$(INTER_HUB_IMAGE_TAG),) + +CORE_HUB_RELEASE ?= core-hub +CORE_HUB_NAMESPACE ?= core-hub +CORE_HUB_CHART ?= charts/core-hub +CORE_HUB_VALUES ?= helm/core-hub-values.yaml +CORE_HUB_STAGING_NAMESPACE ?= core-hub-staging +CORE_HUB_STAGING_VALUES ?= helm/core-hub-staging-values.yaml +CORE_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/core-hub +CORE_HUB_IMAGE_TAG ?= +CORE_HUB_BASE_URL ?= https://hub.coulomb.social +CORE_HUB_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_IMAGE_REF = $(VERGABE_IMAGE_REPOSITORY):$(VERGABE_IMAGE_TAG) +VERGABE_IMAGE_SET_ARG = $(if $(strip $(VERGABE_IMAGE_TAG)),--set image.repository=$(VERGABE_IMAGE_REPOSITORY) --set image.tag=$(VERGABE_IMAGE_TAG),) +CORE_HUB_IMAGE_REF = $(CORE_HUB_IMAGE_REPOSITORY):$(CORE_HUB_IMAGE_TAG) +CORE_HUB_IMAGE_SET_ARG = $(if $(strip $(CORE_HUB_IMAGE_TAG)),--set image.tag=$(CORE_HUB_IMAGE_TAG),) + +SOPS_SENTINEL ?= $(FORGEJO_SECRETS) +DRY_RUN_CREATE_NAMESPACES ?= false + +##@ Operator checks + +check-tools: ## Check required operator tools and warn about optional diagnostics + tools/check-tools.sh + +check-sops: ## Verify the local SOPS age key can decrypt SOPS_SENTINEL + SOPS_SENTINEL="$(SOPS_SENTINEL)" tools/check-sops.sh + +k8s-server-dry-run: ## Server-side dry-run rendered Helm and committed manifests + DRY_RUN_CREATE_NAMESPACES=$(DRY_RUN_CREATE_NAMESPACES) tools/k8s-server-dry-run.sh + +apps-pg-status: ## Check the shared apps-pg cnpg cluster + @if kubectl cnpg status apps-pg -n databases >/dev/null 2>&1; then \ + kubectl cnpg status apps-pg -n databases; \ + else \ + echo "kubectl cnpg plugin not available; falling back to cnpg resources"; \ + kubectl get cluster apps-pg -n databases; \ + kubectl get pods -n databases -l cnpg.io/cluster=apps-pg; \ + fi + +check-railiance01-kubeconfig: ## Verify Railiance01 production kubeconfig exists + @test -r "$(RAILIANCE01_KUBECONFIG)" || { \ + echo "Missing Railiance01 kubeconfig: $(RAILIANCE01_KUBECONFIG)" >&2; \ + echo "Restore it with:" >&2; \ + echo " ssh tegwick@92.205.62.239 'sudo cat /etc/rancher/k3s/k3s.yaml' | sed 's|127.0.0.1|92.205.62.239|' > $(RAILIANCE01_KUBECONFIG)" >&2; \ + exit 1; \ + } + +check-coulombcore-kubeconfig: ## Verify CoulombCore kubeconfig and k3s API tunnel + @test -r "$(COULOMBCORE_KUBECONFIG)" || { \ + echo "Missing CoulombCore kubeconfig: $(COULOMBCORE_KUBECONFIG)" >&2; \ + echo "Ensure ops-bridge tunnel is up: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + @KUBECONFIG="$(COULOMBCORE_KUBECONFIG)" kubectl get --raw=/readyz >/dev/null 2>&1 || { \ + echo "CoulombCore API unreachable via $(COULOMBCORE_KUBECONFIG)." >&2; \ + echo "Run: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + +check-production-kubeconfig: check-coulombcore-kubeconfig ## Verify production app cluster API (CoulombCore) + +check-inter-hub-image-tag: ## Require an explicit inter-hub image tag for production deploys + @test -n "$(INTER_HUB_IMAGE_TAG)" || { \ + echo "Set INTER_HUB_IMAGE_TAG= for inter-hub production deploys." >&2; \ + echo "Example: INTER_HUB_IMAGE_TAG=91037a4 make inter-hub-deploy" >&2; \ + exit 1; \ + } + +check-inter-hub-image: check-inter-hub-image-tag ## Verify the inter-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(INTER_HUB_IMAGE_REF)" + +check-core-hub-image-tag: ## Require an explicit core-hub image tag for production deploys + @test -n "$(CORE_HUB_IMAGE_TAG)" || { \ + echo "Set CORE_HUB_IMAGE_TAG= for core-hub production deploys." >&2; \ + echo "Example: CORE_HUB_IMAGE_TAG=59bf1e4 make core-hub-deploy" >&2; \ + exit 1; \ + } + +check-core-hub-image: check-core-hub-image-tag ## Verify the core-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(CORE_HUB_IMAGE_REF)" + +cnpg-backup-status: check-production-kubeconfig ## Report Option A / ScheduledBackup coverage + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-status.sh + +cnpg-backup-offsite-secret-apply: check-production-kubeconfig ## Materialize databases/cnpg-backup-offsite from OpenBao (T02) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-backup-offsite-secret-dry-run: check-production-kubeconfig ## Dry-run Secret materialization (no apply) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" DRY_RUN=1 tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-option-a-apply: check-production-kubeconfig ## Apply Option A CronJobs + RBAC (production-touching) + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply -f manifests/cnpg-option-a-backup.yaml + +cnpg-option-a-dry-run: check-production-kubeconfig ## Server-side dry-run of Option A manifests + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply --dry-run=server -f manifests/cnpg-option-a-backup.yaml + +cnpg-logical-backup-dry-run: check-production-kubeconfig ## Logical dump + age for all four clusters (no upload) + chmod +x tools/cnpg-logical-backup.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/cnpg-logical-backup.sh + +cnpg-logical-backup: check-production-kubeconfig ## Logical dump + age + Nextcloud for all four clusters + chmod +x tools/cnpg-logical-backup.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/cnpg-logical-backup.sh + +##@ Option A multi-host (platform CLI / activity-core) + +cnpg-option-a-backup-dry-run: ## Platform multi-host Option A dump+age (JSON, no upload) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=1 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +cnpg-option-a-backup: ## Platform multi-host Option A dump+age+upload (JSON) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + @bao kv metadata get platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing or set RAILIANCE_BACKUP_NC_TOKEN." >&2; \ + exit 1; \ + } + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=0 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +apps-pg-backup-dry-run: check-production-kubeconfig ## Logical pg_dump + age encrypt for vergabe_db (no upload) + chmod +x tools/apps-pg-backup-dry-run.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/apps-pg-backup-dry-run.sh + +apps-pg-restore-drill: check-production-kubeconfig ## Isolated vergabe_db restore drill with row-count gate + chmod +x tools/apps-pg-restore-drill.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/apps-pg-restore-drill.sh + +apps-pg-backup: check-production-kubeconfig ## Logical pg_dump + age encrypt + Nextcloud upload for vergabe_db + chmod +x tools/apps-pg-backup-dry-run.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/apps-pg-backup-dry-run.sh + +##@ Vergabe Teilnahme + +check-vergabe-image-tag: ## Require an explicit vergabe-teilnahme image tag for production deploys + @test -n "$(VERGABE_IMAGE_TAG)" || { \ + echo "Set VERGABE_IMAGE_TAG= for vergabe production deploys." >&2; \ + echo "Example: VERGABE_IMAGE_TAG=1061f3e make vergabe-deploy" >&2; \ + exit 1; \ + } + +check-vergabe-image: check-vergabe-image-tag ## Verify the vergabe OCI image exists on Forgejo before deploy + tools/check-oci-image.sh "$(VERGABE_IMAGE_REF)" + +vergabe-dry-run: check-vergabe-image-tag ## helm template render (no apply) for inspection + helm template $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) + +vergabe-deploy: check-production-kubeconfig check-vergabe-image ## Deploy / upgrade vergabe-teilnahme Helm release + KUBECONFIG="$(VERGABE_KUBECONFIG)" helm upgrade --install $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) --create-namespace \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) --wait --timeout 3m + +vergabe-ingress-deploy: ## Apply the vergabe-teilnahme ingress (whywhynot.de) + kubectl apply -f $(VERGABE_INGRESS) + +vergabe-status: check-production-kubeconfig ## Show vergabe-teilnahme pod / svc / ingress / cert state + KUBECONFIG="$(VERGABE_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) --ignore-not-found + +vergabe-migrate: ## Run Django migrations against the live deployment + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py migrate --noinput + +vergabe-seed: ## Run the idempotent seed command + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py seed_dev + +vergabe-superuser: ## Open an interactive shell for createsuperuser + kubectl exec -it -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py createsuperuser + +vergabe-logs: ## Tail vergabe-teilnahme app logs + kubectl logs -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) -f --tail=50 + +vergabe-db-url-secret: ## Rebuild DATABASE_URL with a URL-encoded cnpg password + APP_NAMESPACE=$(VERGABE_NAMESPACE) \ + APP_ENV_SECRET=$(VERGABE_ENV_SECRET) \ + APP_DB_SECRET=$(VERGABE_DB_SECRET) \ + APP_DB_USER=$(VERGABE_DB_USER) \ + APP_DB_HOST=$(VERGABE_DB_HOST) \ + APP_DB_PORT=$(VERGABE_DB_PORT) \ + APP_DB_NAME=$(VERGABE_DB_NAME) \ + tools/build-database-url-secret.sh + + + +check-coulomb-social-image-tag: ## Require an explicit coulomb-social image tag + @test -n "$(COULOMB_SOCIAL_IMAGE_TAG)" || ( \ + echo "Set COULOMB_SOCIAL_IMAGE_TAG= for coulomb-social deploys." >&2; \ + echo "Example: COULOMB_SOCIAL_IMAGE_TAG=deadbeef make coulomb-social-deploy" >&2; \ + exit 1) + +coulomb-social-dry-run: check-coulomb-social-image-tag ## helm template render for coulomb-social + helm template $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) + +coulomb-social-deploy: check-production-kubeconfig check-coulomb-social-image-tag ## Deploy / upgrade coulomb-social + kubectl create namespace $(COULOMB_SOCIAL_NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - + helm upgrade --install $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) \ + --wait --timeout 5m + +coulomb-social-ingress-deploy: ## Apply coulomb-social ingress + kubectl apply -f $(COULOMB_SOCIAL_INGRESS) + +coulomb-social-status: check-production-kubeconfig ## Show coulomb-social pod/svc/ingress + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) get deploy,svc,ingress,pods + +coulomb-social-logs: ## Tail coulomb-social app logs + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) logs -l app.kubernetes.io/name=coulomb-social -f --tail=200 + + +coulomb-social-env-secret: ## Create/patch coulomb-social-env (SECRET_KEY, DATABASE_URL, USER_ENGINE_PROXY_SECRET) + @bash tools/create-coulomb-social-env-secret.sh $(COULOMB_SOCIAL_ENV_SECRET_ARGS) + +coulomb-social-env-secret-dry-run: ## Plan coulomb-social-env keys without applying + @bash tools/create-coulomb-social-env-secret.sh --dry-run $(COULOMB_SOCIAL_ENV_SECRET_ARGS) +##@ Inter-Hub + +inter-hub-render-baseline: ## Render checked-in inter-hub values for chart validation only + helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) + +inter-hub-dry-run: check-railiance01-kubeconfig check-inter-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) + +inter-hub-server-dry-run: check-railiance01-kubeconfig check-inter-hub-image ## Helm server dry-run inter-hub upgrade on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +inter-hub-deploy: check-railiance01-kubeconfig check-inter-hub-image ## Deploy / upgrade inter-hub Helm release on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +inter-hub-status: check-railiance01-kubeconfig ## Show inter-hub pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) --ignore-not-found + +inter-hub-release-info: check-railiance01-kubeconfig ## Show inter-hub Helm history, values, deployment, and pods + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm history $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm get values $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl describe deploy/$(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods -n $(INTER_HUB_NAMESPACE) -o wide + +inter-hub-smoke: ## Verify public inter-hub v2 route and OpenAPI surface after rollout + INTER_HUB_BASE_URL="$(INTER_HUB_BASE_URL)" tools/inter-hub-smoke.sh + +inter-hub-logs: check-railiance01-kubeconfig ## Tail inter-hub app logs from Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl logs -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) -f --tail=50 + +##@ Core Hub + +core-hub-helm-adopt: check-production-kubeconfig ## Annotate live Core Hub resources for Helm ownership + chmod +x tools/core-hub-helm-adopt.sh + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" CORE_HUB_NAMESPACE="$(CORE_HUB_NAMESPACE)" CORE_HUB_RELEASE="$(CORE_HUB_RELEASE)" \ + tools/core-hub-helm-adopt.sh + +core-hub-render-baseline: ## Render checked-in core-hub values for chart validation only + helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) + +core-hub-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-server-dry-run: check-production-kubeconfig check-core-hub-image ## Helm server dry-run core-hub upgrade on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +core-hub-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub Helm release on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations via a one-off Job + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) delete job $(CORE_HUB_RELEASE)-prod-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-prod-migrate --timeout=180s + +core-hub-status: check-production-kubeconfig ## Show core-hub pod / svc / ingress / cert state on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +core-hub-release-info: check-production-kubeconfig ## Show core-hub Helm history, values, deployment, and pods + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm history $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm get values $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl describe deploy/$(CORE_HUB_RELEASE)-api -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods -n $(CORE_HUB_NAMESPACE) -o wide + +core-hub-smoke: ## Verify public Core Hub health and v2 API surface after rollout + CORE_HUB_BASE_URL="$(CORE_HUB_BASE_URL)" tools/core-hub-smoke.sh + +core-hub-logs: check-production-kubeconfig ## Tail core-hub app logs from CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl logs -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub,app.kubernetes.io/component=api -f --tail=50 + +core-hub-staging-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-staging-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub staging release + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-staging-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) delete job $(CORE_HUB_RELEASE)-staging-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-staging-migrate --timeout=180s + +core-hub-staging-status: check-production-kubeconfig ## Show core-hub staging pod / svc state + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc -n $(CORE_HUB_STAGING_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +##@ Forgejo (forgejo.coulomb.social) + +eso-deploy: check-railiance01-kubeconfig ## Install External Secrets Operator on railiance01 + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add external-secrets https://charts.external-secrets.io --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install external-secrets external-secrets/external-secrets \ + --version $(ESO_CHART_VERSION) \ + --namespace external-secrets --create-namespace \ + --set installCRDs=true \ + --set serviceAccount.create=true \ + --set serviceAccount.name=external-secrets \ + --wait --timeout 5m + +forgejo-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (interactive) + tools/forgejo-openbao-eso-token-apply.sh + +forgejo-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-forgejo ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-forgejo.clustersecretstore.yaml" + +forgejo-mailer-es-deploy: check-railiance01-kubeconfig forgejo-openbao-store-deploy ## Apply Forgejo mailer ExternalSecret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_MAILER_EXTERNAL_SECRET) + +forgejo-mailer-es-status: check-railiance01-kubeconfig ## Show Forgejo mailer ExternalSecret sync status + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get externalsecret,secret -n $(FORGEJO_NAMESPACE) forgejo-mailer + +forgejo-dry-run: check-railiance01-kubeconfig ## helm template render for Forgejo + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm template $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) + +forgejo-server-dry-run: check-railiance01-kubeconfig ## Helm server dry-run Forgejo upgrade + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --dry-run=server --timeout 5m + +forgejo-secret-scan-template-deploy: check-railiance01-kubeconfig ## Install the immutable, non-secret receive-hook template + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply --server-side -f $(FORGEJO_SECRET_SCAN_TEMPLATE) + +forgejo-deploy: check-railiance01-kubeconfig forgejo-mailer-es-deploy forgejo-secret-scan-template-deploy ## Deploy / upgrade Forgejo on railiance01 + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-mailer -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-mailer in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Ensure ExternalSecret is Ready and OpenBao role external-secrets-forgejo is configured." >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add gitea-charts https://dl.gitea.com/charts/ --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --wait --timeout 10m + +forgejo-ingress-deploy: check-railiance01-kubeconfig ## Apply Forgejo HTTPS ingress + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_INGRESS) + +forgejo-ssh-nodeport-deploy: check-railiance01-kubeconfig ## Apply Forgejo SSH NodePort (30022) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_SSH_NODEPORT) + +forgejo-status: check-railiance01-kubeconfig ## Show Forgejo pods, svc, ingress, cert, database + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get pods,svc,ingress,certificate,pvc -n $(FORGEJO_NAMESPACE) --ignore-not-found + @if KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) >/dev/null 2>&1; then \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE); \ + else \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get cluster $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) --ignore-not-found; \ + fi + +forgejo-smoke: ## Verify Forgejo web and OCI registry challenge + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-smoke.sh + +forgejo-npm-smoke: ## T07 npm install smoke (@coulomb/forgejo-npm-probe) + tools/forgejo-npm-smoke.sh + +forgejo-verify: check-railiance01-kubeconfig ## T05 acceptance: smoke, TLS, DB, mailer ESO, SSH operator, SOPS sentinels + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-verify.sh + +forgejo-operator-bootstrap: ## Idempotent operator user + SSH key bootstrap (no secrets in Git) + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-operator-bootstrap.sh + +forgejo-breakglass-seal: check-railiance01-kubeconfig ## Founder: rotate break-glass admin password into OpenBao (interactive, production-touching) + FORGEJO_KUBECONFIG="$(FORGEJO_KUBECONFIG)" FORGEJO_NAMESPACE="$(FORGEJO_NAMESPACE)" tools/forgejo-breakglass-seal.sh + +forgejo-breakglass-verify: ## Founder: verify one break-glass sign-in and write value-free evidence + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-breakglass-verify.sh + +forgejo-runner-registration-sops-bootstrap: check-railiance01-kubeconfig ## Capture cluster runner token into SOPS (operator age key) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-runner-registration-sops-bootstrap.sh + +forgejo-secrets-check: ## Verify Forgejo SOPS sentinels exist and are encrypted + @test -f $(FORGEJO_SECRETS) && grep -q '^sops:' $(FORGEJO_SECRETS) || { echo "Missing encrypted $(FORGEJO_SECRETS)" >&2; exit 1; } + @if [ -f $(FORGEJO_RUNNER_REGISTRATION_SECRET) ]; then \ + grep -q '^sops:' $(FORGEJO_RUNNER_REGISTRATION_SECRET) || { echo "Runner registration not SOPS-encrypted" >&2; exit 1; }; \ + else \ + echo "WARN: $(FORGEJO_RUNNER_REGISTRATION_SECRET) missing — run forgejo-runner-registration-sops-bootstrap on operator host" >&2; \ + fi + @echo "ok: Forgejo SOPS sentinels" + +forgejo-logs: check-railiance01-kubeconfig ## Tail Forgejo application logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/instance=$(FORGEJO_RELEASE) -f --tail=50 + +forgejo-runner-registration-deploy: check-railiance01-kubeconfig check-sops ## Apply SOPS runner registration secret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f <(sops -d $(FORGEJO_RUNNER_REGISTRATION_SECRET)) + +forgejo-runner-deploy: check-railiance01-kubeconfig ## Deploy in-cluster Forgejo Actions runner (ADR-004) + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-runner-registration -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-runner-registration in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Apply helm/forgejo-runner-registration.sops.yaml or bootstrap once:" >&2; \ + echo " kubectl create secret generic forgejo-runner-registration -n forgejo --from-literal=token=" >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_RUNNER_MANIFEST) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl rollout status deployment/forgejo-runner -n $(FORGEJO_NAMESPACE) --timeout=5m + +forgejo-runner-status: check-railiance01-kubeconfig ## Show in-cluster runner pod and PVC state + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get deploy,pods,pvc -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/name=forgejo-runner --ignore-not-found + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner --tail=20 2>/dev/null || true + +forgejo-runner-logs: check-railiance01-kubeconfig ## Tail in-cluster runner logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner -f --tail=50 + +##@ reuse-surface (reuse.coulomb.social) + +reuse-dry-run: check-railiance01-kubeconfig ## helm template render (no apply) for reuse-surface + KUBECONFIG="$(REUSE_KUBECONFIG)" helm template $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) \ + -f $(REUSE_VALUES) + +reuse-deploy: check-railiance01-kubeconfig ## Deploy / upgrade reuse-surface Helm release on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" helm upgrade --install $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) --create-namespace \ + -f $(REUSE_VALUES) --wait --timeout 5m + +reuse-status: check-railiance01-kubeconfig ## Show reuse-surface pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get pods,svc,ingress,pvc,certificate -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) --ignore-not-found + +reuse-smoke: check-railiance01-kubeconfig ## Verify reuse public health, repo list, and TLS certificate readiness + curl -fsS "$(REUSE_URL)/health" + curl -fsS "$(REUSE_URL)/v1/repos" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl wait -n $(REUSE_NAMESPACE) --for=condition=Ready certificate/$(REUSE_CERTIFICATE) --timeout=30s + +reuse-logs: check-railiance01-kubeconfig ## Tail reuse-surface service logs from Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl logs -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) -f --tail=50 + +reuse-forgejo-webhook: check-railiance01-kubeconfig ## Idempotent org webhook on Forgejo -> reuse hub (REUSE-WP-0019-T03) + bash tools/reuse-forgejo-webhook.sh + +reuse-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (CCR-2026-0005) + OPENBAO_TOKEN_FILE="$(HOME)/.local/openbao/platform-admin.token" bash tools/reuse-openbao-eso-token-apply.sh + +reuse-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-reuse ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-reuse.clustersecretstore.yaml" + +reuse-runtime-es-deploy: check-railiance01-kubeconfig reuse-openbao-store-deploy ## Apply reuse-surface runtime ExternalSecret + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f $(REUSE_RUNTIME_EXTERNAL_SECRET) + +reuse-runtime-es-status: check-railiance01-kubeconfig ## Show reuse-surface runtime ExternalSecret sync status + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get externalsecret,secret -n $(REUSE_NAMESPACE) reuse-surface-runtime reuse-surface-env + +reuse-webhook-smoke: check-railiance01-kubeconfig ## Webhook + ESO + federated smoke (RAILIANCE-WP-0011-T04) + bash tools/reuse-webhook-smoke.sh + +##@ policy-nexus (policy.coulomb.social) + +policy-nexus-check-binding: ## Require a source-controlled, immutable production binding + @test -d "$(POLICY_NEXUS_RAPP)" || { echo "Missing sibling rApp repo: $(POLICY_NEXUS_RAPP)" >&2; exit 2; } + python3 tools/check-policy-nexus-binding.py "$(POLICY_NEXUS_BINDING)" + +policy-nexus-check-image: policy-nexus-check-binding ## Verify the bound OCI digest exists in Forgejo + tools/check-oci-image.sh "$(POLICY_NEXUS_IMAGE_REPOSITORY)@$(POLICY_NEXUS_IMAGE_DIGEST)" + +policy-nexus-render: policy-nexus-check-binding ## Render the rApp chart with the approved production binding + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" render \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-server-dry-run: policy-nexus-check-binding ## Validate the approved policy package against Railiance01 + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" server-dry-run TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-deploy: policy-nexus-check-binding ## Deploy the approved policy release through its rApp package + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" deploy TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-status: ## Show the policy-nexus Helm release state + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" status TARGET="$(POLICY_NEXUS_TARGET)" + +policy-nexus-smoke: policy-nexus-check-binding ## Verify public content, TLS, and both immutable release identities + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" verify-live TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-rollback: ## Roll back to a named previous Helm revision + @test -n "$(POLICY_NEXUS_REVISION)" || { echo "POLICY_NEXUS_REVISION is required" >&2; exit 2; } + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" rollback TARGET="$(POLICY_NEXUS_TARGET)" REVISION="$(POLICY_NEXUS_REVISION)" + +##@ Help + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} \ + /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + +.PHONY: forgejo-secret-scan-template-deploy diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/process-status.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/process-status.json new file mode 100644 index 00000000..c9dd0030 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/process-status.json @@ -0,0 +1,26 @@ +{ + "all_owned_exec_handles_complete": true, + "live_exec_handles": [], + "live_wait_cells": [], + "live_owned_build_processes": [], + "completed_handles": [ + 40476, + 7562, + 46393, + 15845, + 33305, + 32691, + 43522, + 52717, + 47031, + 86788, + 38632, + 80551, + 50174, + 83373, + 31050, + 33100 + ], + "application_container_started": false, + "note": "Existing Docker daemon/build cache and retained prototype images are not live owned jobs. No restart on observation timeout." +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/apps-release-build-source.patch b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/apps-release-build-source.patch new file mode 100644 index 00000000..3bf1a5f7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/apps-release-build-source.patch @@ -0,0 +1,908 @@ +--- a/Makefile ++++ b/Makefile +@@ -655,3 +655,30 @@ + .PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + + .PHONY: forgejo-secret-scan-template-deploy ++ ++ ++##@ Isolated Forgejo source release preparation (no deployment/publication) ++FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release ++FORGEJO_SOURCE_ARCHIVE ?= ++FORGEJO_GO_ARCHIVE ?= ++FORGEJO_NODE_ARCHIVE ?= ++FORGEJO_BUILD_GOMODCACHE ?= ++FORGEJO_BUILD_GOCACHE_SEED ?= ++FORGEJO_BUILD_NPM_CACHE_SEED ?= ++ ++.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container ++forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace ++ /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") ++ ++forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts ++ /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets ++ /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline ++ /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy ++ /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ +--- /dev/null ++++ b/build/forgejo/README.md +@@ -0,0 +1,72 @@ ++# Forgejo release preparation (build only) ++ ++The Apps Makefile targets package the independently reviewed seven-path Forgejo ++11.0.3 source extension. They never deploy, push, change default values or choose ++an operational image. Third-party application source is expanded only into a new ++`/tmp` workspace; this repository retains a release input lock and exact reviewed ++patch, not a second source-code tree. Upstream source ownership and publication ++remain separate owner work. ++ ++Supply explicit public input archives/cache paths to `make forgejo-release-prepare`: ++`FORGEJO_SOURCE_ARCHIVE`, `FORGEJO_GO_ARCHIVE`, `FORGEJO_NODE_ARCHIVE`, ++`FORGEJO_BUILD_GOMODCACHE`, optionally `FORGEJO_BUILD_GOCACHE_SEED` and ++`FORGEJO_BUILD_NPM_CACHE_SEED`. Every archive, patch, source output, dependency ++lock and selected host compiler tool is digest-bound by `inputs.lock.json`. ++Go's official checksums and Node's official SHASUMS must be independently checked ++when acquiring those public archives. The source archive is official Forgejo ++v11.0.3; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237. ++ ++The workspace must not exist. Existing trials are preserved, never restarted ++because an observer timed out. The recipe's `process.json` records the exact ++active child PID, phase and command, then completion; logs and receipts stay in ++the workspace. Do not run two phases against the same workspace concurrently. ++ ++`make forgejo-release-acquire-npm` explicitly acquires only public registry ++packages, with fresh HOME/config paths, integrity-bound lockfile and lifecycle ++scripts disabled. With the cache ready, `make forgejo-release-build` reruns ++`npm ci --offline --ignore-scripts`, Go module verification, upstream frontend ++compilation and upstream generation/static backend targets inside a network ++namespace without connectivity. It uses the exact Go/Node distributions, local ++compiler inputs, `bindata timetzdata sqlite sqlite_unlock_notify` plus upstream ++static target's `netgo osusergo`, explicit Make GOFLAGS for trimpath/buildvcs and ++fixed file/directory timestamps. Build tags retain the upstream rootless build ++profile. `make forgejo-release-verify` compares every embedded public/options/ ++template file to actual generated source bytes, checks the extension CLI and ++checks the ELF has no dynamic interpreter. These are local artifact checks, ++not application login, DB, native rollout or architecture acceptance. ++ ++For a **local container build** only, first acquire the locked public amd64 ++base with an empty Docker client config and the explicit local socket. Then ++`make forgejo-release-container` uses that exact digest as a build input and a ++minimal context containing only the verified executable and generated Dockerfile. ++It directly exports a local OCI archive with `rewrite-timestamp=true`, no-cache, ++no image load or push. SOURCE_DATE_EPOCH alone does not fix COPY parent-directory ++timestamps. The image epoch is max(source epoch,ceil immutable base created ++instant), so the original base layers remain exact while new metadata is stable. ++The recipe verifies every OCI blob, original base layer prefix, rootless runtime ++configuration, provenance labels and exact final executable. Two no-cache exports ++must reproduce the frozen output image and archive digests before claiming ++reproducibility. The actual local proof receipts are in the accompanying source ++proposal packet, not an admission of the resulting artifact. ++ ++There is no deployment tag, registry publication, RUN directive, privileged ++container or Docker host/secret mount. Public base acquisition and a standard ++local Docker/buildx daemon supporting OCI export/layer timestamp rewriting are ++explicit prerequisites. An unavailable feature is a retained blocker; do not ++switch to a privileged builder or claim a successful image from a backend-only ++binary. Existing contexts/output archives refuse overwrite. Reconcile an actual ++failed export explicitly, retaining original metadata/logs/artifacts; observation ++timeouts never justify another build. Older prototype load/save behavior and ++its config-versus-manifest-ID issue are retained in the source trial history, ++not shipped as a production continuation. ++ ++The static host toolchain is pinned for this Linux amd64 review estate. Broader ++cross-host reproducibility or a different architecture/compiler/libc is a new ++input revision, not an implicit fallback. The glibc static-linker's optional ++SQLite `dlopen` diagnostic is retained; no SQLite-extension runtime compatibility ++or production platform admission is inferred from ELF inspection or asset checks. ++An accepted owner image/source/backend, authenticated access-engine decision, ++private secret-input boundary, type/case/descriptor re-freeze, current operation/ ++inverse sessions, durable journal and application/Helm writer exclusion are ++separate gates. Helm omission/rollback does not remove persisted OAuth source. ++The existing case gate is compatibility code, not a single-PDP exception. +--- /dev/null ++++ b/build/forgejo/inputs.lock.json +@@ -0,0 +1,63 @@ ++{ ++ "schema": "railiance-apps.forgejo-release-inputs.v1", ++ "forgejo_version": "11.0.3", ++ "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", ++ "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", ++ "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257", ++ "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", ++ "go_version": "1.24.3", ++ "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", ++ "node_version": "22.15.0", ++ "source_date_epoch": 1752105237, ++ "release_version": "11.0.3+nk48.f2788b3f", ++ "tags": "bindata timetzdata sqlite sqlite_unlock_notify", ++ "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", ++ "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", ++ "platform": "linux/amd64", ++ "reviewed_outputs": [ ++ { ++ "path": "cmd/admin_auth_oauth.go", ++ "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f" ++ }, ++ { ++ "path": "routers/web/admin/auths.go", ++ "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923" ++ }, ++ { ++ "path": "routers/web/auth/oauth.go", ++ "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b" ++ }, ++ { ++ "path": "services/auth/source/oauth2/providers_openid.go", ++ "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf" ++ }, ++ { ++ "path": "services/auth/source/oauth2/required_claim_values.go", ++ "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda" ++ }, ++ { ++ "path": "services/auth/source/oauth2/required_claim_values_test.go", ++ "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0" ++ }, ++ { ++ "path": "services/auth/source/oauth2/source.go", ++ "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642" ++ } ++ ], ++ "lockfiles": { ++ "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00", ++ "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", ++ "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", ++ "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" ++ }, ++ "host_tools": { ++ "/usr/bin/make": "d78b8f1d099fbcfb6f2f49ab87223b9b68fb3956642f92d6ec6de812e8afa965", ++ "/usr/bin/gcc": "1b99826121ae6682a634e5efe09bd3e3df58ce58e0b28f849114ab5b89139c26", ++ "/usr/bin/ld": "e46a122cc3a29feba3b1d0fc2a274b6e46538be016ebe381370c0801bb98b0b8", ++ "/usr/bin/ar": "534681ac11c18868cfc4fdf98770aa0ba8973eedc90c231e94e6ba96e1a04f27", ++ "/usr/bin/bwrap": "e318903862396f96de3df57264e0158682b952fd3fb53ac23d876413e7b30f71", ++ "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621", ++ "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae" ++ }, ++ "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b" ++} +--- /dev/null ++++ b/build/forgejo/required-claim-values.patch +@@ -0,0 +1,484 @@ ++--- a/cmd/admin_auth_oauth.go +++++ b/cmd/admin_auth_oauth.go ++@@ -94,6 +94,10 @@ ++ Name: "required-claim-value", ++ Value: "", ++ Usage: "Claim value that has to be set to allow users to login with this source", +++ }, +++ &cli.StringSliceFlag{ +++ Name: "required-claim-values", +++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)", ++ }, ++ &cli.StringFlag{ ++ Name: "group-claim-name", ++@@ -160,6 +164,7 @@ ++ Scopes: c.StringSlice("scopes"), ++ RequiredClaimName: c.String("required-claim-name"), ++ RequiredClaimValue: c.String("required-claim-value"), +++ RequiredClaimValues: c.StringSlice("required-claim-values"), ++ GroupClaimName: c.String("group-claim-name"), ++ AdminGroup: c.String("admin-group"), ++ RestrictedGroup: c.String("restricted-group"), ++@@ -177,6 +182,12 @@ ++ } ++ ++ config := parseOAuth2Config(c) +++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil { +++ return err +++ } +++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" { +++ return errors.New("required-claim-values requires openidConnect") +++ } ++ if config.Provider == "openidConnect" { ++ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) ++ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { ++@@ -246,6 +257,10 @@ ++ oAuth2Config.RequiredClaimValue = c.String("required-claim-value") ++ } ++ +++ if c.IsSet("required-claim-values") { +++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values") +++ } +++ ++ if c.IsSet("group-claim-name") { ++ oAuth2Config.GroupClaimName = c.String("group-claim-name") ++ } ++@@ -293,6 +308,12 @@ ++ } ++ ++ oAuth2Config.CustomURLMapping = customURLMapping +++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil { +++ return err +++ } +++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" { +++ return errors.New("required-claim-values requires openidConnect") +++ } ++ source.Cfg = oAuth2Config ++ ++ return auth_model.UpdateSource(ctx, source) ++--- a/routers/web/admin/auths.go +++++ b/routers/web/admin/auths.go ++@@ -322,6 +322,13 @@ ++ ctx.Data["Source"] = source ++ ctx.Data["HasTLS"] = source.HasTLS() ++ +++ // The legacy form has no representation for the opt-in current-UserInfo gate. +++ // Refuse rather than silently dropping it during source reconstruction. +++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil { +++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form) +++ return +++ } +++ ++ if ctx.HasError() { ++ ctx.HTML(http.StatusOK, tplAuthEdit) ++ return ++--- a/routers/web/auth/oauth.go +++++ b/routers/web/auth/oauth.go ++@@ -1427,7 +1427,11 @@ ++ return goth.User{}, err ++ } ++ ++- if oauth2Source.RequiredClaimName != "" { +++ if oauth2Source.RequiredClaimValues != nil { +++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) { +++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} +++ } +++ } else if oauth2Source.RequiredClaimName != "" { ++ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] ++ if !has { ++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} ++--- a/services/auth/source/oauth2/providers_openid.go +++++ b/services/auth/source/oauth2/providers_openid.go ++@@ -4,6 +4,7 @@ ++ package oauth2 ++ ++ import ( +++ "errors" ++ "html/template" ++ ++ "forgejo.org/modules/log" ++@@ -43,6 +44,22 @@ ++ if err != nil { ++ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) ++ } +++ if err != nil { +++ return nil, err +++ } +++ if source.RequiredClaimValues != nil { +++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil { +++ return nil, err +++ } +++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest { +++ return nil, errors.New("required-claim-values needs current UserInfo") +++ } +++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName) +++ if err != nil { +++ return nil, err +++ } +++ provider.HTTPClient = guarded +++ } ++ return provider, err ++ } ++ ++--- /dev/null +++++ b/services/auth/source/oauth2/required_claim_values.go ++@@ -0,0 +1,202 @@ +++// Copyright 2026 The Forgejo Authors. All rights reserved. +++// SPDX-License-Identifier: MIT +++ +++package oauth2 +++ +++import ( +++ "bytes" +++ "encoding/json" +++ "errors" +++ "io" +++ "net/http" +++ "net/url" +++ "strings" +++ "unicode" +++ "unicode/utf8" +++) +++ +++const maxClaimValues = 1024 +++const maxUserInfoBytes = 1 << 20 +++ +++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil +++// list retains every legacy single-value behavior. An explicitly empty list +++// fails closed rather than turning a configured gate into claim-presence-only. +++func ValidateRequiredClaimValues(name, legacy string, values []string) error { +++ if values == nil { +++ return nil +++ } +++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 { +++ return errors.New("invalid or conflicting required-claim-values configuration") +++ } +++ seen := map[string]bool{} +++ for _, value := range values { +++ if !validClaimLiteral(value) || seen[value] { +++ return errors.New("invalid required-claim-values literal") +++ } +++ seen[value] = true +++ } +++ return nil +++} +++ +++func validClaimLiteral(value string) bool { +++ return value != "" && len(value) <= 256 && utf8.ValidString(value) && +++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") && +++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError }) +++} +++ +++// strictClaimValues accepts only a well-formed claim array. No scalar parsing, +++// coercion, regex or prefix matching is introduced into the new gate. +++func strictClaimValues(raw any) ([]string, bool) { +++ var values []string +++ switch v := raw.(type) { +++ case []string: +++ values = v +++ case []any: +++ values = make([]string, len(v)) +++ for i, value := range v { +++ s, ok := value.(string) +++ if !ok { +++ return nil, false +++ } +++ values[i] = s +++ } +++ default: +++ return nil, false +++ } +++ if len(values) > maxClaimValues { +++ return nil, false +++ } +++ for _, value := range values { +++ if !validClaimLiteral(value) { +++ return nil, false +++ } +++ } +++ return values, true +++} +++ +++// MatchRequiredClaimValues evaluates the new consumer gate against current +++// authenticated provider data; it does not mutate or manufacture issuer claims. +++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool { +++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil { +++ return false +++ } +++ raw, present := claims[name] +++ if !present { +++ return false +++ } +++ values, ok := strictClaimValues(raw) +++ if !ok { +++ return false +++ } +++ for _, value := range values { +++ for _, wanted := range required { +++ if value == wanted { +++ return true +++ } +++ } +++ } +++ return false +++} +++ +++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and +++// adds a strict response guard for this opt-in source only. A current UserInfo +++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP +++// failures cannot fall back to the ID-token map in Goth's merging code. +++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) { +++ u, err := url.Parse(endpoint) +++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) { +++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint") +++ } +++ if client == nil { +++ client = http.DefaultClient +++ } +++ result := *client +++ base := result.Transport +++ if base == nil { +++ base = http.DefaultTransport +++ } +++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim} +++ // Redirects could evade endpoint matching or send a bearer to another origin. +++ // They are unsupported for the new guarded provider; legacy clients are intact. +++ result.CheckRedirect = func(*http.Request, []*http.Request) error { +++ return errors.New("guarded OAuth redirects are not supported") +++ } +++ return &result, nil +++} +++ +++type currentClaimTransport struct { +++ base http.RoundTripper +++ endpoint, claim string +++} +++ +++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) { +++ response, err := t.base.RoundTrip(req) +++ if req.URL.String() != t.endpoint { +++ return response, err +++ } +++ if err != nil { +++ if response != nil && response.Body != nil { +++ response.Body.Close() +++ } +++ return nil, errors.New("current UserInfo lookup failed") +++ } +++ if response == nil || response.Body == nil { +++ return nil, errors.New("current UserInfo response missing") +++ } +++ defer response.Body.Close() +++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK { +++ return nil, errors.New("current UserInfo refused") +++ } +++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1)) +++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) { +++ return nil, errors.New("current UserInfo response invalid") +++ } +++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting +++ // groups or subject claim. json.RawMessage permits unrelated normal claims. +++ decoder := json.NewDecoder(bytes.NewReader(body)) +++ token, err := decoder.Token() +++ if err != nil || token != json.Delim('{') { +++ return nil, errors.New("current UserInfo object required") +++ } +++ claims := map[string]json.RawMessage{} +++ for decoder.More() { +++ keyToken, err := decoder.Token() +++ if err != nil { +++ return nil, errors.New("current UserInfo key invalid") +++ } +++ key, ok := keyToken.(string) +++ if !ok { +++ return nil, errors.New("current UserInfo key invalid") +++ } +++ if _, duplicate := claims[key]; duplicate { +++ return nil, errors.New("current UserInfo duplicate claim") +++ } +++ var value json.RawMessage +++ if decoder.Decode(&value) != nil { +++ return nil, errors.New("current UserInfo value invalid") +++ } +++ claims[key] = value +++ } +++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') { +++ return nil, errors.New("current UserInfo object invalid") +++ } +++ var trailing any +++ if decoder.Decode(&trailing) != io.EOF { +++ return nil, errors.New("current UserInfo trailing data") +++ } +++ raw, present := claims[t.claim] +++ if !present { +++ return nil, errors.New("current UserInfo required claim missing") +++ } +++ var value any +++ if json.Unmarshal(raw, &value) != nil { +++ return nil, errors.New("current UserInfo required claim invalid") +++ } +++ if _, ok := strictClaimValues(value); !ok { +++ return nil, errors.New("current UserInfo required claim array invalid") +++ } +++ // Return the original public response bytes to Goth. Subject equality and +++ // audience/issuer checks remain in its existing authenticated provider path. +++ response.Body = io.NopCloser(bytes.NewReader(body)) +++ return response, nil +++} ++--- /dev/null +++++ b/services/auth/source/oauth2/required_claim_values_test.go ++@@ -0,0 +1,144 @@ +++// Copyright 2026 The Forgejo Authors. All rights reserved. +++// SPDX-License-Identifier: MIT +++package oauth2 +++ +++import ( +++ "errors" +++ "io" +++ "net/http" +++ "reflect" +++ "strings" +++ "testing" +++) +++ +++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} +++ +++type roundTripFunc func(*http.Request) (*http.Response, error) +++ +++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +++ +++func mockUserInfo(body string, status int) *http.Client { +++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { +++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil +++ })} +++} +++ +++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) { +++ for _, group := range caseValues { +++ groups := []any{group, "unrelated"} +++ claims := map[string]any{"groups": groups, "other": "preserved"} +++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) { +++ t.Fatal("individual allowed group rejected") +++ } +++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) { +++ t.Fatal("claims mutated") +++ } +++ } +++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) { +++ t.Fatal("multiple allowed groups rejected") +++ } +++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} { +++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) { +++ t.Fatal("malformed/nonmatching claim accepted") +++ } +++ } +++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) { +++ t.Fatal("absent claim accepted") +++ } +++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) { +++ t.Fatal("unrelated claim used") +++ } +++} +++ +++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) { +++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil { +++ t.Fatal("legacy config changed") +++ } +++ for _, cfg := range []struct { +++ name, legacy string +++ values []string +++ }{ +++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues}, +++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}}, +++ } { +++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil { +++ t.Fatal("invalid config accepted") +++ } +++ } +++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil { +++ t.Fatal("case config invalid") +++ } +++} +++ +++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) { +++ for _, body := range []string{ +++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`, +++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`, +++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`, +++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1), +++ } { +++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := client.Get("https://issuer.example/userinfo") +++ if err == nil { +++ response.Body.Close() +++ t.Fatal("invalid current claim did not fail closed") +++ } +++ } +++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups") +++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil { +++ response.Body.Close() +++ t.Fatal("non200 accepted") +++ } +++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })} +++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups") +++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") { +++ t.Fatal("lookup failure not contained") +++ } +++} +++ +++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) { +++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} { +++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := client.Get("https://issuer.example/userinfo") +++ if err != nil { +++ t.Fatal(err) +++ } +++ b, _ := io.ReadAll(response.Body) +++ response.Body.Close() +++ if string(b) != body { +++ t.Fatal("response changed") +++ } +++ } +++} +++ +++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) { +++ original := mockUserInfo(`{"unrelated":true}`, 200) +++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := original.Get("https://issuer.example/userinfo") +++ if err != nil { +++ t.Fatal("original client mutated") +++ } +++ response.Body.Close() +++ response, err = guarded.Get("https://issuer.example/unrelated") +++ if err != nil { +++ t.Fatal("unrelated endpoint changed") +++ } +++ response.Body.Close() +++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil { +++ t.Fatal("redirect can bypass guard") +++ } +++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} { +++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil { +++ t.Fatal("invalid endpoint accepted") +++ } +++ } +++} ++--- a/services/auth/source/oauth2/source.go +++++ b/services/auth/source/oauth2/source.go ++@@ -22,6 +22,7 @@ ++ Scopes []string ++ AttributeSSHPublicKey string ++ RequiredClaimName string +++ RequiredClaimValues []string `json:",omitempty"` ++ RequiredClaimValue string ++ GroupClaimName string ++ AdminGroup string +--- /dev/null ++++ b/build/forgejo/runtime-base-config.json +@@ -0,0 +1 @@ ++{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}} +\ No newline at end of file +--- /dev/null ++++ b/tools/forgejo-release-build.py +@@ -0,0 +1,239 @@ ++#!/usr/bin/env python3 ++"""Build-only public Forgejo owner recipe; no deployment or publication code.""" ++import argparse ++import hashlib ++import json ++import os ++from pathlib import Path ++import shutil ++import subprocess ++import tarfile ++import time ++import math ++import datetime ++ ++OWNER=Path(__file__).resolve().parents[1] ++INPUTS=OWNER/'build/forgejo' ++LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) ++ ++def digest(path): ++ h=hashlib.sha256() ++ with Path(path).open('rb') as f: ++ for block in iter(lambda:f.read(1024*1024),b''):h.update(block) ++ return h.hexdigest() ++ ++def save(path,data): ++ Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') ++ ++def check(path,expected): ++ if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) ++ ++def base_env(work): ++ return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', ++ 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', ++ 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], ++ 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', ++ 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), ++ 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', ++ 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', ++ 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), ++ 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', ++ 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', ++ 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} ++ ++def sandbox(work,env): ++ cache=Path(env['GOMODCACHE']) ++ return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', ++ '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', ++ '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), ++ '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] ++ ++def run(work,name,args,env=None,offline=False,cwd=None): ++ actual=sandbox(work,env)+args if offline else args ++ start=time.time() ++ with (work/(name+'.log')).open('w') as out: ++ proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) ++ save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) ++ code=proc.wait() ++ result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} ++ save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) ++ print(json.dumps(result),flush=True) ++ if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) ++ return result ++ ++def checked_inputs(work): ++ for n,h in LOCK['host_tools'].items():check(n,h) ++ for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) ++ for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) ++ ++def prepare(a,work): ++ if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') ++ for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: ++ if not getattr(a,n):raise ValueError('explicit '+n+' required') ++ check(getattr(a,n),LOCK[key]) ++ if not a.module_cache:raise ValueError('explicit public module cache required') ++ cache=Path(a.module_cache).resolve();assert cache.is_dir() ++ check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) ++ for n,h in LOCK['host_tools'].items():check(n,h) ++ work.mkdir(parents=True);(work/'home').mkdir() ++ with tarfile.open(a.source_archive) as t: ++ # The official archive is digest-bound before any member is extracted. ++ parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') ++ folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() ++ for n in ['go_archive','node_archive']: ++ with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') ++ for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') ++ if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') ++ if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') ++ subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) ++ subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) ++ save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) ++ checked_inputs(work) ++ packages=json.loads((work/'source/package-lock.json').read_text())['packages'] ++ for name,package in packages.items(): ++ if not name:continue ++ if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): ++ raise ValueError('non-public/non-integrity-bound npm package: '+name) ++ print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) ++ ++def normalize(folder): ++ epoch=LOCK['source_date_epoch'] ++ for root,dirs,files in os.walk(folder): ++ dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] ++ for n in files: ++ p=Path(root)/n ++ if not p.is_symlink():os.utime(p,(epoch,epoch)) ++ os.utime(root,(epoch,epoch)) ++ ++def build(work): ++ checked_inputs(work);env=base_env(work) ++ run(work,'verify-go-modules',['go','mod','verify'],env,True) ++ run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) ++ normalize(work/'source') ++ version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] ++ run(work,'frontend',['make']+version+['frontend'],env,True) ++ for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) ++ run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) ++ checked_inputs(work) ++ save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) ++ ++def verify(work): ++ checked_inputs(work);binary=work/'source/gitea' ++ target=work/'embedded-extracted' ++ if target.exists():raise ValueError('retain prior verification; extraction target already exists') ++ target.mkdir() ++ # No source, asset tree, host /usr, libraries, configuration or HOME is ++ # exposed to the executable. A filesystem fallback cannot pass this proof. ++ bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', ++ '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', ++ '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] ++ for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', ++ 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', ++ 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): ++ bare+=['--setenv',k,v] ++ bare+=['--','/app/forgejo'] ++ outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} ++ run(work,'binary-version',bare+['--version'],outer) ++ run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) ++ assert '--required-claim-values' in (work/'new-cli-help.log').read_text() ++ run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) ++ records={} ++ for folder in ['public','options','templates']: ++ for f in sorted((work/'source'/folder).rglob('*')): ++ if not f.is_file():continue ++ n=str(f.relative_to(work/'source'));q=target/n ++ if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) ++ records[n]=digest(f) ++ assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records ++ assert 'public/assets/licenses.txt' in records ++ save(work/'embedded-files.json',records) ++ readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) ++ assert 'INTERP' not in readelf.stdout ++ save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), ++ 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, ++ 'no_source_assets_libraries_or_host_config_mounted':True, ++ 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', ++ 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) ++ print((work/'verification.json').read_text()) ++ ++def container(work): ++ verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) ++ context=work/'container-context' ++ if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') ++ archive_path=work/'image.oci.tar' ++ if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') ++ base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() ++ assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] ++ original=json.loads(base_bytes) ++ epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) ++ context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') ++ os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) ++ (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') ++ os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) ++ config=work/'docker-config';config.mkdir(exist_ok=True) ++ env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} ++ # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. ++ # OCI output permits layer rewriting without the Docker-store unpack conflict. ++ # The epoch never predates the immutable base, preserving its exact layer prefix. ++ run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', ++ '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', ++ '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) ++ metadata=json.loads((work/'image-build-metadata.json').read_text()) ++ with tarfile.open(archive_path) as archive: ++ index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) ++ assert len(index['manifests'])==1 ++ descriptor=index['manifests'][0] ++ assert descriptor['platform']=={'architecture':'amd64','os':'linux'} ++ assert descriptor['digest']==metadata['containerimage.digest'] ++ def blob(d): ++ value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() ++ assert 'sha256:'+hashlib.sha256(value).hexdigest()==d ++ return value ++ manifest=json.loads(blob(descriptor['digest'])) ++ config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) ++ assert config_digest==metadata['containerimage.config.digest'] ++ for layer in manifest['layers']:blob(layer['digest']) ++ assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] ++ assert config['architecture']=='amd64' and config['os']=='linux' ++ for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: ++ assert config['config'].get(key)==original['config'].get(key),key ++ labels=dict(original['config'].get('Labels',{})) ++ labels.update({'org.opencontainers.image.version':LOCK['release_version'], ++ 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) ++ assert config['config'].get('Labels')==labels ++ assert config['config']['User']=='1000:1000' ++ expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') ++ assert config['created']==expected_created ++ found=False ++ last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] ++ with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: ++ for member in layer: ++ name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') ++ if name=='app/gitea/gitea': ++ h=hashlib.sha256();stream=layer.extractfile(member) ++ for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) ++ assert h.hexdigest()==verified['binary_sha256'];found=True ++ assert found ++ save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, ++ 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), ++ 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, ++ 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, ++ 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, ++ 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) ++ print((work/'container-result.json').read_text()) ++ ++def main(): ++ parser=argparse.ArgumentParser(description=__doc__) ++ parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) ++ parser.add_argument('--workspace',required=True) ++ for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) ++ a=parser.parse_args();work=Path(a.workspace).resolve() ++ if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') ++ if a.phase=='prepare':return prepare(a,work) ++ assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') ++ if a.phase=='acquire-npm': ++ checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' ++ return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') ++ {'build':build,'verify':verify,'container':container}[a.phase](work) ++ ++if __name__=='__main__':main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/failed-patch-generation-r1.patch b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/failed-patch-generation-r1.patch new file mode 100644 index 00000000..182a367d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/failed-patch-generation-r1.patch @@ -0,0 +1,917 @@ +diff --git a/Makefile b/Makefile +--- a/Makefile ++++ b/Makefile +@@ -655,3 +655,30 @@ + .PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + + .PHONY: forgejo-secret-scan-template-deploy ++ ++ ++##@ Isolated Forgejo source release preparation (no deployment/publication) ++FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release ++FORGEJO_SOURCE_ARCHIVE ?= ++FORGEJO_GO_ARCHIVE ?= ++FORGEJO_NODE_ARCHIVE ?= ++FORGEJO_BUILD_GOMODCACHE ?= ++FORGEJO_BUILD_GOCACHE_SEED ?= ++FORGEJO_BUILD_NPM_CACHE_SEED ?= ++ ++.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container ++forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace ++ /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") ++ ++forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts ++ /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets ++ /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline ++ /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ ++forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy ++ /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" ++ +diff --git a/build/forgejo/README.md b/build/forgejo/README.md +new file mode 100644 +--- /dev/null ++++ b/build/forgejo/README.md +@@ -0,0 +1,72 @@ ++# Forgejo release preparation (build only) ++ ++The Apps Makefile targets package the independently reviewed seven-path Forgejo ++11.0.3 source extension. They never deploy, push, change default values or choose ++an operational image. Third-party application source is expanded only into a new ++`/tmp` workspace; this repository retains a release input lock and exact reviewed ++patch, not a second source-code tree. Upstream source ownership and publication ++remain separate owner work. ++ ++Supply explicit public input archives/cache paths to `make forgejo-release-prepare`: ++`FORGEJO_SOURCE_ARCHIVE`, `FORGEJO_GO_ARCHIVE`, `FORGEJO_NODE_ARCHIVE`, ++`FORGEJO_BUILD_GOMODCACHE`, optionally `FORGEJO_BUILD_GOCACHE_SEED` and ++`FORGEJO_BUILD_NPM_CACHE_SEED`. Every archive, patch, source output, dependency ++lock and selected host compiler tool is digest-bound by `inputs.lock.json`. ++Go's official checksums and Node's official SHASUMS must be independently checked ++when acquiring those public archives. The source archive is official Forgejo ++v11.0.3; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237. ++ ++The workspace must not exist. Existing trials are preserved, never restarted ++because an observer timed out. The recipe's `process.json` records the exact ++active child PID, phase and command, then completion; logs and receipts stay in ++the workspace. Do not run two phases against the same workspace concurrently. ++ ++`make forgejo-release-acquire-npm` explicitly acquires only public registry ++packages, with fresh HOME/config paths, integrity-bound lockfile and lifecycle ++scripts disabled. With the cache ready, `make forgejo-release-build` reruns ++`npm ci --offline --ignore-scripts`, Go module verification, upstream frontend ++compilation and upstream generation/static backend targets inside a network ++namespace without connectivity. It uses the exact Go/Node distributions, local ++compiler inputs, `bindata timetzdata sqlite sqlite_unlock_notify` plus upstream ++static target's `netgo osusergo`, explicit Make GOFLAGS for trimpath/buildvcs and ++fixed file/directory timestamps. Build tags retain the upstream rootless build ++profile. `make forgejo-release-verify` compares every embedded public/options/ ++template file to actual generated source bytes, checks the extension CLI and ++checks the ELF has no dynamic interpreter. These are local artifact checks, ++not application login, DB, native rollout or architecture acceptance. ++ ++For a **local container build** only, first acquire the locked public amd64 ++base with an empty Docker client config and the explicit local socket. Then ++`make forgejo-release-container` uses that exact digest as a build input and a ++minimal context containing only the verified executable and generated Dockerfile. ++It directly exports a local OCI archive with `rewrite-timestamp=true`, no-cache, ++no image load or push. SOURCE_DATE_EPOCH alone does not fix COPY parent-directory ++timestamps. The image epoch is max(source epoch,ceil immutable base created ++instant), so the original base layers remain exact while new metadata is stable. ++The recipe verifies every OCI blob, original base layer prefix, rootless runtime ++configuration, provenance labels and exact final executable. Two no-cache exports ++must reproduce the frozen output image and archive digests before claiming ++reproducibility. The actual local proof receipts are in the accompanying source ++proposal packet, not an admission of the resulting artifact. ++ ++There is no deployment tag, registry publication, RUN directive, privileged ++container or Docker host/secret mount. Public base acquisition and a standard ++local Docker/buildx daemon supporting OCI export/layer timestamp rewriting are ++explicit prerequisites. An unavailable feature is a retained blocker; do not ++switch to a privileged builder or claim a successful image from a backend-only ++binary. Existing contexts/output archives refuse overwrite. Reconcile an actual ++failed export explicitly, retaining original metadata/logs/artifacts; observation ++timeouts never justify another build. Older prototype load/save behavior and ++its config-versus-manifest-ID issue are retained in the source trial history, ++not shipped as a production continuation. ++ ++The static host toolchain is pinned for this Linux amd64 review estate. Broader ++cross-host reproducibility or a different architecture/compiler/libc is a new ++input revision, not an implicit fallback. The glibc static-linker's optional ++SQLite `dlopen` diagnostic is retained; no SQLite-extension runtime compatibility ++or production platform admission is inferred from ELF inspection or asset checks. ++An accepted owner image/source/backend, authenticated access-engine decision, ++private secret-input boundary, type/case/descriptor re-freeze, current operation/ ++inverse sessions, durable journal and application/Helm writer exclusion are ++separate gates. Helm omission/rollback does not remove persisted OAuth source. ++The existing case gate is compatibility code, not a single-PDP exception. +diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json +new file mode 100644 +--- /dev/null ++++ b/build/forgejo/inputs.lock.json +@@ -0,0 +1,63 @@ ++{ ++ "schema": "railiance-apps.forgejo-release-inputs.v1", ++ "forgejo_version": "11.0.3", ++ "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", ++ "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", ++ "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257", ++ "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", ++ "go_version": "1.24.3", ++ "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", ++ "node_version": "22.15.0", ++ "source_date_epoch": 1752105237, ++ "release_version": "11.0.3+nk48.f2788b3f", ++ "tags": "bindata timetzdata sqlite sqlite_unlock_notify", ++ "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", ++ "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", ++ "platform": "linux/amd64", ++ "reviewed_outputs": [ ++ { ++ "path": "cmd/admin_auth_oauth.go", ++ "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f" ++ }, ++ { ++ "path": "routers/web/admin/auths.go", ++ "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923" ++ }, ++ { ++ "path": "routers/web/auth/oauth.go", ++ "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b" ++ }, ++ { ++ "path": "services/auth/source/oauth2/providers_openid.go", ++ "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf" ++ }, ++ { ++ "path": "services/auth/source/oauth2/required_claim_values.go", ++ "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda" ++ }, ++ { ++ "path": "services/auth/source/oauth2/required_claim_values_test.go", ++ "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0" ++ }, ++ { ++ "path": "services/auth/source/oauth2/source.go", ++ "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642" ++ } ++ ], ++ "lockfiles": { ++ "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00", ++ "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", ++ "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", ++ "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" ++ }, ++ "host_tools": { ++ "/usr/bin/make": "d78b8f1d099fbcfb6f2f49ab87223b9b68fb3956642f92d6ec6de812e8afa965", ++ "/usr/bin/gcc": "1b99826121ae6682a634e5efe09bd3e3df58ce58e0b28f849114ab5b89139c26", ++ "/usr/bin/ld": "e46a122cc3a29feba3b1d0fc2a274b6e46538be016ebe381370c0801bb98b0b8", ++ "/usr/bin/ar": "534681ac11c18868cfc4fdf98770aa0ba8973eedc90c231e94e6ba96e1a04f27", ++ "/usr/bin/bwrap": "e318903862396f96de3df57264e0158682b952fd3fb53ac23d876413e7b30f71", ++ "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621", ++ "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae" ++ }, ++ "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b" ++} +diff --git a/build/forgejo/required-claim-values.patch b/build/forgejo/required-claim-values.patch +new file mode 100644 +--- /dev/null ++++ b/build/forgejo/required-claim-values.patch +@@ -0,0 +1,484 @@ ++--- a/cmd/admin_auth_oauth.go +++++ b/cmd/admin_auth_oauth.go ++@@ -94,6 +94,10 @@ ++ Name: "required-claim-value", ++ Value: "", ++ Usage: "Claim value that has to be set to allow users to login with this source", +++ }, +++ &cli.StringSliceFlag{ +++ Name: "required-claim-values", +++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)", ++ }, ++ &cli.StringFlag{ ++ Name: "group-claim-name", ++@@ -160,6 +164,7 @@ ++ Scopes: c.StringSlice("scopes"), ++ RequiredClaimName: c.String("required-claim-name"), ++ RequiredClaimValue: c.String("required-claim-value"), +++ RequiredClaimValues: c.StringSlice("required-claim-values"), ++ GroupClaimName: c.String("group-claim-name"), ++ AdminGroup: c.String("admin-group"), ++ RestrictedGroup: c.String("restricted-group"), ++@@ -177,6 +182,12 @@ ++ } ++ ++ config := parseOAuth2Config(c) +++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil { +++ return err +++ } +++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" { +++ return errors.New("required-claim-values requires openidConnect") +++ } ++ if config.Provider == "openidConnect" { ++ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) ++ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { ++@@ -246,6 +257,10 @@ ++ oAuth2Config.RequiredClaimValue = c.String("required-claim-value") ++ } ++ +++ if c.IsSet("required-claim-values") { +++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values") +++ } +++ ++ if c.IsSet("group-claim-name") { ++ oAuth2Config.GroupClaimName = c.String("group-claim-name") ++ } ++@@ -293,6 +308,12 @@ ++ } ++ ++ oAuth2Config.CustomURLMapping = customURLMapping +++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil { +++ return err +++ } +++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" { +++ return errors.New("required-claim-values requires openidConnect") +++ } ++ source.Cfg = oAuth2Config ++ ++ return auth_model.UpdateSource(ctx, source) ++--- a/routers/web/admin/auths.go +++++ b/routers/web/admin/auths.go ++@@ -322,6 +322,13 @@ ++ ctx.Data["Source"] = source ++ ctx.Data["HasTLS"] = source.HasTLS() ++ +++ // The legacy form has no representation for the opt-in current-UserInfo gate. +++ // Refuse rather than silently dropping it during source reconstruction. +++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil { +++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form) +++ return +++ } +++ ++ if ctx.HasError() { ++ ctx.HTML(http.StatusOK, tplAuthEdit) ++ return ++--- a/routers/web/auth/oauth.go +++++ b/routers/web/auth/oauth.go ++@@ -1427,7 +1427,11 @@ ++ return goth.User{}, err ++ } ++ ++- if oauth2Source.RequiredClaimName != "" { +++ if oauth2Source.RequiredClaimValues != nil { +++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) { +++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} +++ } +++ } else if oauth2Source.RequiredClaimName != "" { ++ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] ++ if !has { ++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} ++--- a/services/auth/source/oauth2/providers_openid.go +++++ b/services/auth/source/oauth2/providers_openid.go ++@@ -4,6 +4,7 @@ ++ package oauth2 ++ ++ import ( +++ "errors" ++ "html/template" ++ ++ "forgejo.org/modules/log" ++@@ -43,6 +44,22 @@ ++ if err != nil { ++ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) ++ } +++ if err != nil { +++ return nil, err +++ } +++ if source.RequiredClaimValues != nil { +++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil { +++ return nil, err +++ } +++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest { +++ return nil, errors.New("required-claim-values needs current UserInfo") +++ } +++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName) +++ if err != nil { +++ return nil, err +++ } +++ provider.HTTPClient = guarded +++ } ++ return provider, err ++ } ++ ++--- /dev/null +++++ b/services/auth/source/oauth2/required_claim_values.go ++@@ -0,0 +1,202 @@ +++// Copyright 2026 The Forgejo Authors. All rights reserved. +++// SPDX-License-Identifier: MIT +++ +++package oauth2 +++ +++import ( +++ "bytes" +++ "encoding/json" +++ "errors" +++ "io" +++ "net/http" +++ "net/url" +++ "strings" +++ "unicode" +++ "unicode/utf8" +++) +++ +++const maxClaimValues = 1024 +++const maxUserInfoBytes = 1 << 20 +++ +++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil +++// list retains every legacy single-value behavior. An explicitly empty list +++// fails closed rather than turning a configured gate into claim-presence-only. +++func ValidateRequiredClaimValues(name, legacy string, values []string) error { +++ if values == nil { +++ return nil +++ } +++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 { +++ return errors.New("invalid or conflicting required-claim-values configuration") +++ } +++ seen := map[string]bool{} +++ for _, value := range values { +++ if !validClaimLiteral(value) || seen[value] { +++ return errors.New("invalid required-claim-values literal") +++ } +++ seen[value] = true +++ } +++ return nil +++} +++ +++func validClaimLiteral(value string) bool { +++ return value != "" && len(value) <= 256 && utf8.ValidString(value) && +++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") && +++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError }) +++} +++ +++// strictClaimValues accepts only a well-formed claim array. No scalar parsing, +++// coercion, regex or prefix matching is introduced into the new gate. +++func strictClaimValues(raw any) ([]string, bool) { +++ var values []string +++ switch v := raw.(type) { +++ case []string: +++ values = v +++ case []any: +++ values = make([]string, len(v)) +++ for i, value := range v { +++ s, ok := value.(string) +++ if !ok { +++ return nil, false +++ } +++ values[i] = s +++ } +++ default: +++ return nil, false +++ } +++ if len(values) > maxClaimValues { +++ return nil, false +++ } +++ for _, value := range values { +++ if !validClaimLiteral(value) { +++ return nil, false +++ } +++ } +++ return values, true +++} +++ +++// MatchRequiredClaimValues evaluates the new consumer gate against current +++// authenticated provider data; it does not mutate or manufacture issuer claims. +++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool { +++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil { +++ return false +++ } +++ raw, present := claims[name] +++ if !present { +++ return false +++ } +++ values, ok := strictClaimValues(raw) +++ if !ok { +++ return false +++ } +++ for _, value := range values { +++ for _, wanted := range required { +++ if value == wanted { +++ return true +++ } +++ } +++ } +++ return false +++} +++ +++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and +++// adds a strict response guard for this opt-in source only. A current UserInfo +++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP +++// failures cannot fall back to the ID-token map in Goth's merging code. +++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) { +++ u, err := url.Parse(endpoint) +++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) { +++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint") +++ } +++ if client == nil { +++ client = http.DefaultClient +++ } +++ result := *client +++ base := result.Transport +++ if base == nil { +++ base = http.DefaultTransport +++ } +++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim} +++ // Redirects could evade endpoint matching or send a bearer to another origin. +++ // They are unsupported for the new guarded provider; legacy clients are intact. +++ result.CheckRedirect = func(*http.Request, []*http.Request) error { +++ return errors.New("guarded OAuth redirects are not supported") +++ } +++ return &result, nil +++} +++ +++type currentClaimTransport struct { +++ base http.RoundTripper +++ endpoint, claim string +++} +++ +++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) { +++ response, err := t.base.RoundTrip(req) +++ if req.URL.String() != t.endpoint { +++ return response, err +++ } +++ if err != nil { +++ if response != nil && response.Body != nil { +++ response.Body.Close() +++ } +++ return nil, errors.New("current UserInfo lookup failed") +++ } +++ if response == nil || response.Body == nil { +++ return nil, errors.New("current UserInfo response missing") +++ } +++ defer response.Body.Close() +++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK { +++ return nil, errors.New("current UserInfo refused") +++ } +++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1)) +++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) { +++ return nil, errors.New("current UserInfo response invalid") +++ } +++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting +++ // groups or subject claim. json.RawMessage permits unrelated normal claims. +++ decoder := json.NewDecoder(bytes.NewReader(body)) +++ token, err := decoder.Token() +++ if err != nil || token != json.Delim('{') { +++ return nil, errors.New("current UserInfo object required") +++ } +++ claims := map[string]json.RawMessage{} +++ for decoder.More() { +++ keyToken, err := decoder.Token() +++ if err != nil { +++ return nil, errors.New("current UserInfo key invalid") +++ } +++ key, ok := keyToken.(string) +++ if !ok { +++ return nil, errors.New("current UserInfo key invalid") +++ } +++ if _, duplicate := claims[key]; duplicate { +++ return nil, errors.New("current UserInfo duplicate claim") +++ } +++ var value json.RawMessage +++ if decoder.Decode(&value) != nil { +++ return nil, errors.New("current UserInfo value invalid") +++ } +++ claims[key] = value +++ } +++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') { +++ return nil, errors.New("current UserInfo object invalid") +++ } +++ var trailing any +++ if decoder.Decode(&trailing) != io.EOF { +++ return nil, errors.New("current UserInfo trailing data") +++ } +++ raw, present := claims[t.claim] +++ if !present { +++ return nil, errors.New("current UserInfo required claim missing") +++ } +++ var value any +++ if json.Unmarshal(raw, &value) != nil { +++ return nil, errors.New("current UserInfo required claim invalid") +++ } +++ if _, ok := strictClaimValues(value); !ok { +++ return nil, errors.New("current UserInfo required claim array invalid") +++ } +++ // Return the original public response bytes to Goth. Subject equality and +++ // audience/issuer checks remain in its existing authenticated provider path. +++ response.Body = io.NopCloser(bytes.NewReader(body)) +++ return response, nil +++} ++--- /dev/null +++++ b/services/auth/source/oauth2/required_claim_values_test.go ++@@ -0,0 +1,144 @@ +++// Copyright 2026 The Forgejo Authors. All rights reserved. +++// SPDX-License-Identifier: MIT +++package oauth2 +++ +++import ( +++ "errors" +++ "io" +++ "net/http" +++ "reflect" +++ "strings" +++ "testing" +++) +++ +++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} +++ +++type roundTripFunc func(*http.Request) (*http.Response, error) +++ +++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +++ +++func mockUserInfo(body string, status int) *http.Client { +++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { +++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil +++ })} +++} +++ +++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) { +++ for _, group := range caseValues { +++ groups := []any{group, "unrelated"} +++ claims := map[string]any{"groups": groups, "other": "preserved"} +++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) { +++ t.Fatal("individual allowed group rejected") +++ } +++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) { +++ t.Fatal("claims mutated") +++ } +++ } +++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) { +++ t.Fatal("multiple allowed groups rejected") +++ } +++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} { +++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) { +++ t.Fatal("malformed/nonmatching claim accepted") +++ } +++ } +++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) { +++ t.Fatal("absent claim accepted") +++ } +++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) { +++ t.Fatal("unrelated claim used") +++ } +++} +++ +++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) { +++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil { +++ t.Fatal("legacy config changed") +++ } +++ for _, cfg := range []struct { +++ name, legacy string +++ values []string +++ }{ +++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues}, +++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}}, +++ } { +++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil { +++ t.Fatal("invalid config accepted") +++ } +++ } +++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil { +++ t.Fatal("case config invalid") +++ } +++} +++ +++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) { +++ for _, body := range []string{ +++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`, +++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`, +++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`, +++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1), +++ } { +++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := client.Get("https://issuer.example/userinfo") +++ if err == nil { +++ response.Body.Close() +++ t.Fatal("invalid current claim did not fail closed") +++ } +++ } +++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups") +++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil { +++ response.Body.Close() +++ t.Fatal("non200 accepted") +++ } +++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })} +++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups") +++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") { +++ t.Fatal("lookup failure not contained") +++ } +++} +++ +++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) { +++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} { +++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := client.Get("https://issuer.example/userinfo") +++ if err != nil { +++ t.Fatal(err) +++ } +++ b, _ := io.ReadAll(response.Body) +++ response.Body.Close() +++ if string(b) != body { +++ t.Fatal("response changed") +++ } +++ } +++} +++ +++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) { +++ original := mockUserInfo(`{"unrelated":true}`, 200) +++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups") +++ if err != nil { +++ t.Fatal(err) +++ } +++ response, err := original.Get("https://issuer.example/userinfo") +++ if err != nil { +++ t.Fatal("original client mutated") +++ } +++ response.Body.Close() +++ response, err = guarded.Get("https://issuer.example/unrelated") +++ if err != nil { +++ t.Fatal("unrelated endpoint changed") +++ } +++ response.Body.Close() +++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil { +++ t.Fatal("redirect can bypass guard") +++ } +++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} { +++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil { +++ t.Fatal("invalid endpoint accepted") +++ } +++ } +++} ++--- a/services/auth/source/oauth2/source.go +++++ b/services/auth/source/oauth2/source.go ++@@ -22,6 +22,7 @@ ++ Scopes []string ++ AttributeSSHPublicKey string ++ RequiredClaimName string +++ RequiredClaimValues []string `json:",omitempty"` ++ RequiredClaimValue string ++ GroupClaimName string ++ AdminGroup string +diff --git a/build/forgejo/runtime-base-config.json b/build/forgejo/runtime-base-config.json +new file mode 100644 +--- /dev/null ++++ b/build/forgejo/runtime-base-config.json +@@ -0,0 +1 @@ ++{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}}diff --git a/tools/forgejo-release-build.py b/tools/forgejo-release-build.py +new file mode 100644 +--- /dev/null ++++ b/tools/forgejo-release-build.py +@@ -0,0 +1,239 @@ ++#!/usr/bin/env python3 ++"""Build-only public Forgejo owner recipe; no deployment or publication code.""" ++import argparse ++import hashlib ++import json ++import os ++from pathlib import Path ++import shutil ++import subprocess ++import tarfile ++import time ++import math ++import datetime ++ ++OWNER=Path(__file__).resolve().parents[1] ++INPUTS=OWNER/'build/forgejo' ++LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) ++ ++def digest(path): ++ h=hashlib.sha256() ++ with Path(path).open('rb') as f: ++ for block in iter(lambda:f.read(1024*1024),b''):h.update(block) ++ return h.hexdigest() ++ ++def save(path,data): ++ Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') ++ ++def check(path,expected): ++ if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) ++ ++def base_env(work): ++ return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', ++ 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', ++ 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], ++ 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', ++ 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), ++ 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', ++ 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', ++ 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), ++ 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', ++ 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', ++ 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} ++ ++def sandbox(work,env): ++ cache=Path(env['GOMODCACHE']) ++ return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', ++ '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', ++ '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), ++ '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] ++ ++def run(work,name,args,env=None,offline=False,cwd=None): ++ actual=sandbox(work,env)+args if offline else args ++ start=time.time() ++ with (work/(name+'.log')).open('w') as out: ++ proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) ++ save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) ++ code=proc.wait() ++ result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} ++ save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) ++ print(json.dumps(result),flush=True) ++ if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) ++ return result ++ ++def checked_inputs(work): ++ for n,h in LOCK['host_tools'].items():check(n,h) ++ for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) ++ for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) ++ ++def prepare(a,work): ++ if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') ++ for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: ++ if not getattr(a,n):raise ValueError('explicit '+n+' required') ++ check(getattr(a,n),LOCK[key]) ++ if not a.module_cache:raise ValueError('explicit public module cache required') ++ cache=Path(a.module_cache).resolve();assert cache.is_dir() ++ check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) ++ for n,h in LOCK['host_tools'].items():check(n,h) ++ work.mkdir(parents=True);(work/'home').mkdir() ++ with tarfile.open(a.source_archive) as t: ++ # The official archive is digest-bound before any member is extracted. ++ parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') ++ folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() ++ for n in ['go_archive','node_archive']: ++ with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') ++ for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') ++ if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') ++ if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') ++ subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) ++ subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) ++ save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) ++ checked_inputs(work) ++ packages=json.loads((work/'source/package-lock.json').read_text())['packages'] ++ for name,package in packages.items(): ++ if not name:continue ++ if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): ++ raise ValueError('non-public/non-integrity-bound npm package: '+name) ++ print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) ++ ++def normalize(folder): ++ epoch=LOCK['source_date_epoch'] ++ for root,dirs,files in os.walk(folder): ++ dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] ++ for n in files: ++ p=Path(root)/n ++ if not p.is_symlink():os.utime(p,(epoch,epoch)) ++ os.utime(root,(epoch,epoch)) ++ ++def build(work): ++ checked_inputs(work);env=base_env(work) ++ run(work,'verify-go-modules',['go','mod','verify'],env,True) ++ run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) ++ normalize(work/'source') ++ version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] ++ run(work,'frontend',['make']+version+['frontend'],env,True) ++ for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) ++ run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) ++ checked_inputs(work) ++ save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) ++ ++def verify(work): ++ checked_inputs(work);binary=work/'source/gitea' ++ target=work/'embedded-extracted' ++ if target.exists():raise ValueError('retain prior verification; extraction target already exists') ++ target.mkdir() ++ # No source, asset tree, host /usr, libraries, configuration or HOME is ++ # exposed to the executable. A filesystem fallback cannot pass this proof. ++ bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', ++ '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', ++ '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] ++ for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', ++ 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', ++ 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): ++ bare+=['--setenv',k,v] ++ bare+=['--','/app/forgejo'] ++ outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} ++ run(work,'binary-version',bare+['--version'],outer) ++ run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) ++ assert '--required-claim-values' in (work/'new-cli-help.log').read_text() ++ run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) ++ records={} ++ for folder in ['public','options','templates']: ++ for f in sorted((work/'source'/folder).rglob('*')): ++ if not f.is_file():continue ++ n=str(f.relative_to(work/'source'));q=target/n ++ if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) ++ records[n]=digest(f) ++ assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records ++ assert 'public/assets/licenses.txt' in records ++ save(work/'embedded-files.json',records) ++ readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) ++ assert 'INTERP' not in readelf.stdout ++ save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), ++ 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, ++ 'no_source_assets_libraries_or_host_config_mounted':True, ++ 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', ++ 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) ++ print((work/'verification.json').read_text()) ++ ++def container(work): ++ verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) ++ context=work/'container-context' ++ if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') ++ archive_path=work/'image.oci.tar' ++ if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') ++ base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() ++ assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] ++ original=json.loads(base_bytes) ++ epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) ++ context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') ++ os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) ++ (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') ++ os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) ++ config=work/'docker-config';config.mkdir(exist_ok=True) ++ env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} ++ # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. ++ # OCI output permits layer rewriting without the Docker-store unpack conflict. ++ # The epoch never predates the immutable base, preserving its exact layer prefix. ++ run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', ++ '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', ++ '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) ++ metadata=json.loads((work/'image-build-metadata.json').read_text()) ++ with tarfile.open(archive_path) as archive: ++ index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) ++ assert len(index['manifests'])==1 ++ descriptor=index['manifests'][0] ++ assert descriptor['platform']=={'architecture':'amd64','os':'linux'} ++ assert descriptor['digest']==metadata['containerimage.digest'] ++ def blob(d): ++ value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() ++ assert 'sha256:'+hashlib.sha256(value).hexdigest()==d ++ return value ++ manifest=json.loads(blob(descriptor['digest'])) ++ config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) ++ assert config_digest==metadata['containerimage.config.digest'] ++ for layer in manifest['layers']:blob(layer['digest']) ++ assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] ++ assert config['architecture']=='amd64' and config['os']=='linux' ++ for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: ++ assert config['config'].get(key)==original['config'].get(key),key ++ labels=dict(original['config'].get('Labels',{})) ++ labels.update({'org.opencontainers.image.version':LOCK['release_version'], ++ 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) ++ assert config['config'].get('Labels')==labels ++ assert config['config']['User']=='1000:1000' ++ expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') ++ assert config['created']==expected_created ++ found=False ++ last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] ++ with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: ++ for member in layer: ++ name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') ++ if name=='app/gitea/gitea': ++ h=hashlib.sha256();stream=layer.extractfile(member) ++ for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) ++ assert h.hexdigest()==verified['binary_sha256'];found=True ++ assert found ++ save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, ++ 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), ++ 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, ++ 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, ++ 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, ++ 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) ++ print((work/'container-result.json').read_text()) ++ ++def main(): ++ parser=argparse.ArgumentParser(description=__doc__) ++ parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) ++ parser.add_argument('--workspace',required=True) ++ for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) ++ a=parser.parse_args();work=Path(a.workspace).resolve() ++ if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') ++ if a.phase=='prepare':return prepare(a,work) ++ assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') ++ if a.phase=='acquire-npm': ++ checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' ++ return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') ++ {'build':build,'verify':verify,'container':container}[a.phase](work) ++ ++if __name__=='__main__':main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/Makefile b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/Makefile new file mode 100644 index 00000000..d08e729f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/Makefile @@ -0,0 +1,684 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +VERGABE_RELEASE ?= vergabe-teilnahme +VERGABE_NAMESPACE ?= vergabe-teilnahme +VERGABE_CHART ?= charts/vergabe-teilnahme +VERGABE_VALUES ?= helm/vergabe-teilnahme-values.yaml +VERGABE_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/vergabe-teilnahme +VERGABE_IMAGE_TAG ?= +VERGABE_INGRESS ?= manifests/vergabe-teilnahme-ingress.yaml + +VERGABE_DB_SECRET ?= vergabe-app-credentials +VERGABE_ENV_SECRET ?= vergabe-teilnahme-env +VERGABE_DB_USER ?= vergabe +VERGABE_DB_HOST ?= apps-pg-rw.databases +VERGABE_DB_PORT ?= 5432 +VERGABE_DB_NAME ?= vergabe_db + +COULOMB_SOCIAL_RELEASE ?= coulomb-social +COULOMB_SOCIAL_NAMESPACE ?= coulomb-social +COULOMB_SOCIAL_CHART ?= charts/coulomb-social +COULOMB_SOCIAL_VALUES ?= helm/coulomb-social-values.yaml +COULOMB_SOCIAL_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/coulomb-social +COULOMB_SOCIAL_INGRESS ?= manifests/coulomb-social-ingress.yaml +COULOMB_SOCIAL_ENV_SECRET ?= coulomb-social-env + +INTER_HUB_RELEASE ?= inter-hub +INTER_HUB_NAMESPACE ?= inter-hub +INTER_HUB_CHART ?= charts/inter-hub +INTER_HUB_VALUES ?= helm/inter-hub-values.yaml +INTER_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/inter-hub +INTER_HUB_IMAGE_TAG ?= +INTER_HUB_BASE_URL ?= https://hub.coulomb.social + +REUSE_RELEASE ?= reuse +REUSE_NAMESPACE ?= reuse +REUSE_CHART ?= charts/reuse-surface +REUSE_VALUES ?= helm/reuse-surface-values.yaml +REUSE_URL ?= https://reuse.coulomb.social +REUSE_CERTIFICATE ?= reuse-surface-tls + +POLICY_NEXUS_RAPP ?= ../rapp-policy-nexus +POLICY_NEXUS_BINDING ?= bindings/policy-nexus-production.json +POLICY_NEXUS_TARGET ?= railiance01 +POLICY_NEXUS_IMAGE_REPOSITORY ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)"))["image_repository"])') +POLICY_NEXUS_IMAGE_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("image_digest") or "")') +POLICY_NEXUS_PUBLICATION_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("publication_manifest_digest") or "")') +POLICY_NEXUS_SOURCE_INVENTORY_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_inventory_digest") or "")') +POLICY_NEXUS_SOURCE_SET_DIGEST ?= $(shell python3 -c 'import json; print(json.load(open("$(POLICY_NEXUS_BINDING)")).get("source_set_digest") or "")') +POLICY_NEXUS_REVISION ?= + +COULOMBCORE_KUBECONFIG ?= $(HOME)/.kube/config +RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-railiance01 +PRODUCTION_KUBECONFIG ?= $(COULOMBCORE_KUBECONFIG) +INTER_HUB_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +REUSE_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) +FORGEJO_RELEASE ?= forgejo +FORGEJO_NAMESPACE ?= forgejo +FORGEJO_CHART ?= gitea-charts/gitea +# Forgejo 11.x (Gitea 1.22) lacks `gitea config edit-ini`; chart 12.6+ requires Gitea 1.26+. +FORGEJO_CHART_VERSION ?= 12.5.0 +FORGEJO_VALUES ?= helm/forgejo-values.yaml +FORGEJO_REGISTRY_VALUES ?= helm/forgejo-registry-values.yaml +FORGEJO_SECRETS ?= helm/forgejo-secrets.sops.yaml +FORGEJO_SECRET_SCAN_TEMPLATE ?= manifests/forgejo-secret-scan-template.yaml +FORGEJO_INGRESS ?= manifests/forgejo-ingress.yaml +FORGEJO_SSH_NODEPORT ?= manifests/forgejo-ssh-nodeport.yaml +FORGEJO_BASE_URL ?= https://forgejo.coulomb.social +FORGEJO_DB_CLUSTER ?= forgejo-db +FORGEJO_DB_NAMESPACE ?= databases +FORGEJO_RUNNER_MANIFEST ?= manifests/forgejo-runner.yaml +FORGEJO_RUNNER_REGISTRATION_SECRET ?= helm/forgejo-runner-registration.sops.yaml +FORGEJO_MAILER_EXTERNAL_SECRET ?= manifests/forgejo-mailer-externalsecret.yaml +REUSE_RUNTIME_EXTERNAL_SECRET ?= manifests/reuse-surface-runtime-externalsecret.yaml +PLATFORM_REPO ?= $(HOME)/railiance-platform +ESO_CHART_VERSION ?= 0.16.1 +INTER_HUB_IMAGE_REF = $(INTER_HUB_IMAGE_REPOSITORY):$(INTER_HUB_IMAGE_TAG) +INTER_HUB_IMAGE_SET_ARG = $(if $(strip $(INTER_HUB_IMAGE_TAG)),--set image.tag=$(INTER_HUB_IMAGE_TAG),) + +CORE_HUB_RELEASE ?= core-hub +CORE_HUB_NAMESPACE ?= core-hub +CORE_HUB_CHART ?= charts/core-hub +CORE_HUB_VALUES ?= helm/core-hub-values.yaml +CORE_HUB_STAGING_NAMESPACE ?= core-hub-staging +CORE_HUB_STAGING_VALUES ?= helm/core-hub-staging-values.yaml +CORE_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/core-hub +CORE_HUB_IMAGE_TAG ?= +CORE_HUB_BASE_URL ?= https://hub.coulomb.social +CORE_HUB_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_KUBECONFIG ?= $(PRODUCTION_KUBECONFIG) +VERGABE_IMAGE_REF = $(VERGABE_IMAGE_REPOSITORY):$(VERGABE_IMAGE_TAG) +VERGABE_IMAGE_SET_ARG = $(if $(strip $(VERGABE_IMAGE_TAG)),--set image.repository=$(VERGABE_IMAGE_REPOSITORY) --set image.tag=$(VERGABE_IMAGE_TAG),) +CORE_HUB_IMAGE_REF = $(CORE_HUB_IMAGE_REPOSITORY):$(CORE_HUB_IMAGE_TAG) +CORE_HUB_IMAGE_SET_ARG = $(if $(strip $(CORE_HUB_IMAGE_TAG)),--set image.tag=$(CORE_HUB_IMAGE_TAG),) + +SOPS_SENTINEL ?= $(FORGEJO_SECRETS) +DRY_RUN_CREATE_NAMESPACES ?= false + +##@ Operator checks + +check-tools: ## Check required operator tools and warn about optional diagnostics + tools/check-tools.sh + +check-sops: ## Verify the local SOPS age key can decrypt SOPS_SENTINEL + SOPS_SENTINEL="$(SOPS_SENTINEL)" tools/check-sops.sh + +k8s-server-dry-run: ## Server-side dry-run rendered Helm and committed manifests + DRY_RUN_CREATE_NAMESPACES=$(DRY_RUN_CREATE_NAMESPACES) tools/k8s-server-dry-run.sh + +apps-pg-status: ## Check the shared apps-pg cnpg cluster + @if kubectl cnpg status apps-pg -n databases >/dev/null 2>&1; then \ + kubectl cnpg status apps-pg -n databases; \ + else \ + echo "kubectl cnpg plugin not available; falling back to cnpg resources"; \ + kubectl get cluster apps-pg -n databases; \ + kubectl get pods -n databases -l cnpg.io/cluster=apps-pg; \ + fi + +check-railiance01-kubeconfig: ## Verify Railiance01 production kubeconfig exists + @test -r "$(RAILIANCE01_KUBECONFIG)" || { \ + echo "Missing Railiance01 kubeconfig: $(RAILIANCE01_KUBECONFIG)" >&2; \ + echo "Restore it with:" >&2; \ + echo " ssh tegwick@92.205.62.239 'sudo cat /etc/rancher/k3s/k3s.yaml' | sed 's|127.0.0.1|92.205.62.239|' > $(RAILIANCE01_KUBECONFIG)" >&2; \ + exit 1; \ + } + +check-coulombcore-kubeconfig: ## Verify CoulombCore kubeconfig and k3s API tunnel + @test -r "$(COULOMBCORE_KUBECONFIG)" || { \ + echo "Missing CoulombCore kubeconfig: $(COULOMBCORE_KUBECONFIG)" >&2; \ + echo "Ensure ops-bridge tunnel is up: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + @KUBECONFIG="$(COULOMBCORE_KUBECONFIG)" kubectl get --raw=/readyz >/dev/null 2>&1 || { \ + echo "CoulombCore API unreachable via $(COULOMBCORE_KUBECONFIG)." >&2; \ + echo "Run: bridge up k3s-api-coulombcore" >&2; \ + exit 1; \ + } + +check-production-kubeconfig: check-coulombcore-kubeconfig ## Verify production app cluster API (CoulombCore) + +check-inter-hub-image-tag: ## Require an explicit inter-hub image tag for production deploys + @test -n "$(INTER_HUB_IMAGE_TAG)" || { \ + echo "Set INTER_HUB_IMAGE_TAG= for inter-hub production deploys." >&2; \ + echo "Example: INTER_HUB_IMAGE_TAG=91037a4 make inter-hub-deploy" >&2; \ + exit 1; \ + } + +check-inter-hub-image: check-inter-hub-image-tag ## Verify the inter-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(INTER_HUB_IMAGE_REF)" + +check-core-hub-image-tag: ## Require an explicit core-hub image tag for production deploys + @test -n "$(CORE_HUB_IMAGE_TAG)" || { \ + echo "Set CORE_HUB_IMAGE_TAG= for core-hub production deploys." >&2; \ + echo "Example: CORE_HUB_IMAGE_TAG=59bf1e4 make core-hub-deploy" >&2; \ + exit 1; \ + } + +check-core-hub-image: check-core-hub-image-tag ## Verify the core-hub OCI image tag exists before deploy + tools/check-oci-image.sh "$(CORE_HUB_IMAGE_REF)" + +cnpg-backup-status: check-production-kubeconfig ## Report Option A / ScheduledBackup coverage + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-status.sh + +cnpg-backup-offsite-secret-apply: check-production-kubeconfig ## Materialize databases/cnpg-backup-offsite from OpenBao (T02) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-backup-offsite-secret-dry-run: check-production-kubeconfig ## Dry-run Secret materialization (no apply) + chmod +x tools/cnpg-backup-offsite-secret-apply.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" DRY_RUN=1 tools/cnpg-backup-offsite-secret-apply.sh + +cnpg-option-a-apply: check-production-kubeconfig ## Apply Option A CronJobs + RBAC (production-touching) + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply -f manifests/cnpg-option-a-backup.yaml + +cnpg-option-a-dry-run: check-production-kubeconfig ## Server-side dry-run of Option A manifests + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" kubectl apply --dry-run=server -f manifests/cnpg-option-a-backup.yaml + +cnpg-logical-backup-dry-run: check-production-kubeconfig ## Logical dump + age for all four clusters (no upload) + chmod +x tools/cnpg-logical-backup.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/cnpg-logical-backup.sh + +cnpg-logical-backup: check-production-kubeconfig ## Logical dump + age + Nextcloud for all four clusters + chmod +x tools/cnpg-logical-backup.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/cnpg-logical-backup.sh + +##@ Option A multi-host (platform CLI / activity-core) + +cnpg-option-a-backup-dry-run: ## Platform multi-host Option A dump+age (JSON, no upload) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=1 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +cnpg-option-a-backup: ## Platform multi-host Option A dump+age+upload (JSON) + chmod +x "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + @bao kv metadata get platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing or set RAILIANCE_BACKUP_NC_TOKEN." >&2; \ + exit 1; \ + } + KUBECONFIG_CORE="$(COULOMBCORE_KUBECONFIG)" KUBECONFIG_R01="$(RAILIANCE01_KUBECONFIG)" \ + RAILIANCE_BACKUP_DRY_RUN=0 "$(PLATFORM_REPO)/tools/cmd/cnpg-option-a-backup" + +apps-pg-backup-dry-run: check-production-kubeconfig ## Logical pg_dump + age encrypt for vergabe_db (no upload) + chmod +x tools/apps-pg-backup-dry-run.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=1 \ + tools/apps-pg-backup-dry-run.sh + +apps-pg-restore-drill: check-production-kubeconfig ## Isolated vergabe_db restore drill with row-count gate + chmod +x tools/apps-pg-restore-drill.sh + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" tools/apps-pg-restore-drill.sh + +apps-pg-backup: check-production-kubeconfig ## Logical pg_dump + age encrypt + Nextcloud upload for vergabe_db + chmod +x tools/apps-pg-backup-dry-run.sh + @KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" bao kv metadata get \ + platform/workloads/railiance/backup/offsite-lane >/dev/null 2>&1 || { \ + echo "ERROR: OpenBao backup-lane auth missing." >&2; \ + echo "Run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2; \ + exit 1; \ + } + KUBECONFIG="$(PRODUCTION_KUBECONFIG)" PLATFORM_REPO="$(PLATFORM_REPO)" RAILIANCE_BACKUP_DRY_RUN=0 \ + tools/apps-pg-backup-dry-run.sh + +##@ Vergabe Teilnahme + +check-vergabe-image-tag: ## Require an explicit vergabe-teilnahme image tag for production deploys + @test -n "$(VERGABE_IMAGE_TAG)" || { \ + echo "Set VERGABE_IMAGE_TAG= for vergabe production deploys." >&2; \ + echo "Example: VERGABE_IMAGE_TAG=1061f3e make vergabe-deploy" >&2; \ + exit 1; \ + } + +check-vergabe-image: check-vergabe-image-tag ## Verify the vergabe OCI image exists on Forgejo before deploy + tools/check-oci-image.sh "$(VERGABE_IMAGE_REF)" + +vergabe-dry-run: check-vergabe-image-tag ## helm template render (no apply) for inspection + helm template $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) + +vergabe-deploy: check-production-kubeconfig check-vergabe-image ## Deploy / upgrade vergabe-teilnahme Helm release + KUBECONFIG="$(VERGABE_KUBECONFIG)" helm upgrade --install $(VERGABE_RELEASE) $(VERGABE_CHART) \ + --namespace $(VERGABE_NAMESPACE) --create-namespace \ + -f $(VERGABE_VALUES) $(VERGABE_IMAGE_SET_ARG) --wait --timeout 3m + +vergabe-ingress-deploy: ## Apply the vergabe-teilnahme ingress (whywhynot.de) + kubectl apply -f $(VERGABE_INGRESS) + +vergabe-status: check-production-kubeconfig ## Show vergabe-teilnahme pod / svc / ingress / cert state + KUBECONFIG="$(VERGABE_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) --ignore-not-found + +vergabe-migrate: ## Run Django migrations against the live deployment + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py migrate --noinput + +vergabe-seed: ## Run the idempotent seed command + kubectl exec -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py seed_dev + +vergabe-superuser: ## Open an interactive shell for createsuperuser + kubectl exec -it -n $(VERGABE_NAMESPACE) deploy/$(VERGABE_RELEASE) -- python manage.py createsuperuser + +vergabe-logs: ## Tail vergabe-teilnahme app logs + kubectl logs -n $(VERGABE_NAMESPACE) -l app.kubernetes.io/instance=$(VERGABE_RELEASE) -f --tail=50 + +vergabe-db-url-secret: ## Rebuild DATABASE_URL with a URL-encoded cnpg password + APP_NAMESPACE=$(VERGABE_NAMESPACE) \ + APP_ENV_SECRET=$(VERGABE_ENV_SECRET) \ + APP_DB_SECRET=$(VERGABE_DB_SECRET) \ + APP_DB_USER=$(VERGABE_DB_USER) \ + APP_DB_HOST=$(VERGABE_DB_HOST) \ + APP_DB_PORT=$(VERGABE_DB_PORT) \ + APP_DB_NAME=$(VERGABE_DB_NAME) \ + tools/build-database-url-secret.sh + + + +check-coulomb-social-image-tag: ## Require an explicit coulomb-social image tag + @test -n "$(COULOMB_SOCIAL_IMAGE_TAG)" || ( \ + echo "Set COULOMB_SOCIAL_IMAGE_TAG= for coulomb-social deploys." >&2; \ + echo "Example: COULOMB_SOCIAL_IMAGE_TAG=deadbeef make coulomb-social-deploy" >&2; \ + exit 1) + +coulomb-social-dry-run: check-coulomb-social-image-tag ## helm template render for coulomb-social + helm template $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) + +coulomb-social-deploy: check-production-kubeconfig check-coulomb-social-image-tag ## Deploy / upgrade coulomb-social + kubectl create namespace $(COULOMB_SOCIAL_NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - + helm upgrade --install $(COULOMB_SOCIAL_RELEASE) $(COULOMB_SOCIAL_CHART) \ + -f $(COULOMB_SOCIAL_VALUES) \ + --set-string image.tag=$(COULOMB_SOCIAL_IMAGE_TAG) \ + --namespace $(COULOMB_SOCIAL_NAMESPACE) \ + --wait --timeout 5m + +coulomb-social-ingress-deploy: ## Apply coulomb-social ingress + kubectl apply -f $(COULOMB_SOCIAL_INGRESS) + +coulomb-social-status: check-production-kubeconfig ## Show coulomb-social pod/svc/ingress + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) get deploy,svc,ingress,pods + +coulomb-social-logs: ## Tail coulomb-social app logs + kubectl -n $(COULOMB_SOCIAL_NAMESPACE) logs -l app.kubernetes.io/name=coulomb-social -f --tail=200 + + +coulomb-social-env-secret: ## Create/patch coulomb-social-env (SECRET_KEY, DATABASE_URL, USER_ENGINE_PROXY_SECRET) + @bash tools/create-coulomb-social-env-secret.sh $(COULOMB_SOCIAL_ENV_SECRET_ARGS) + +coulomb-social-env-secret-dry-run: ## Plan coulomb-social-env keys without applying + @bash tools/create-coulomb-social-env-secret.sh --dry-run $(COULOMB_SOCIAL_ENV_SECRET_ARGS) +##@ Inter-Hub + +inter-hub-render-baseline: ## Render checked-in inter-hub values for chart validation only + helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) + +inter-hub-dry-run: check-railiance01-kubeconfig check-inter-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) + +inter-hub-server-dry-run: check-railiance01-kubeconfig check-inter-hub-image ## Helm server dry-run inter-hub upgrade on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +inter-hub-deploy: check-railiance01-kubeconfig check-inter-hub-image ## Deploy / upgrade inter-hub Helm release on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ + --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ + -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +inter-hub-status: check-railiance01-kubeconfig ## Show inter-hub pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) --ignore-not-found + +inter-hub-release-info: check-railiance01-kubeconfig ## Show inter-hub Helm history, values, deployment, and pods + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm history $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm get values $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl describe deploy/$(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods -n $(INTER_HUB_NAMESPACE) -o wide + +inter-hub-smoke: ## Verify public inter-hub v2 route and OpenAPI surface after rollout + INTER_HUB_BASE_URL="$(INTER_HUB_BASE_URL)" tools/inter-hub-smoke.sh + +inter-hub-logs: check-railiance01-kubeconfig ## Tail inter-hub app logs from Railiance01 + KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl logs -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) -f --tail=50 + +##@ Core Hub + +core-hub-helm-adopt: check-production-kubeconfig ## Annotate live Core Hub resources for Helm ownership + chmod +x tools/core-hub-helm-adopt.sh + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" CORE_HUB_NAMESPACE="$(CORE_HUB_NAMESPACE)" CORE_HUB_RELEASE="$(CORE_HUB_RELEASE)" \ + tools/core-hub-helm-adopt.sh + +core-hub-render-baseline: ## Render checked-in core-hub values for chart validation only + helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) + +core-hub-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render with an explicit production image tag + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-server-dry-run: check-production-kubeconfig check-core-hub-image ## Helm server dry-run core-hub upgrade on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m + +core-hub-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub Helm release on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations via a one-off Job + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) delete job $(CORE_HUB_RELEASE)-prod-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_NAMESPACE) \ + -f $(CORE_HUB_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-prod-migrate --timeout=180s + +core-hub-status: check-production-kubeconfig ## Show core-hub pod / svc / ingress / cert state on CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +core-hub-release-info: check-production-kubeconfig ## Show core-hub Helm history, values, deployment, and pods + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm history $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm get values $(CORE_HUB_RELEASE) -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl describe deploy/$(CORE_HUB_RELEASE)-api -n $(CORE_HUB_NAMESPACE) + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods -n $(CORE_HUB_NAMESPACE) -o wide + +core-hub-smoke: ## Verify public Core Hub health and v2 API surface after rollout + CORE_HUB_BASE_URL="$(CORE_HUB_BASE_URL)" tools/core-hub-smoke.sh + +core-hub-logs: check-production-kubeconfig ## Tail core-hub app logs from CoulombCore + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl logs -n $(CORE_HUB_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub,app.kubernetes.io/component=api -f --tail=50 + +core-hub-staging-dry-run: check-production-kubeconfig check-core-hub-image-tag ## helm template render for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) + +core-hub-staging-deploy: check-production-kubeconfig check-core-hub-image ## Deploy / upgrade core-hub staging release + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm upgrade --install $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) --create-namespace \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) --wait --timeout 5m + +core-hub-staging-migrate: check-production-kubeconfig check-core-hub-image ## Run Alembic migrations for core-hub staging + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) delete job $(CORE_HUB_RELEASE)-staging-migrate --ignore-not-found + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" helm template $(CORE_HUB_RELEASE) $(CORE_HUB_CHART) \ + --namespace $(CORE_HUB_STAGING_NAMESPACE) \ + -f $(CORE_HUB_STAGING_VALUES) $(CORE_HUB_IMAGE_SET_ARG) \ + --set migration.enabled=true | KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl apply -f - + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl -n $(CORE_HUB_STAGING_NAMESPACE) wait --for=condition=complete \ + job/$(CORE_HUB_RELEASE)-staging-migrate --timeout=180s + +core-hub-staging-status: check-production-kubeconfig ## Show core-hub staging pod / svc state + KUBECONFIG="$(CORE_HUB_KUBECONFIG)" kubectl get pods,svc -n $(CORE_HUB_STAGING_NAMESPACE) \ + -l app.kubernetes.io/name=core-hub --ignore-not-found + +##@ Forgejo (forgejo.coulomb.social) + +eso-deploy: check-railiance01-kubeconfig ## Install External Secrets Operator on railiance01 + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add external-secrets https://charts.external-secrets.io --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install external-secrets external-secrets/external-secrets \ + --version $(ESO_CHART_VERSION) \ + --namespace external-secrets --create-namespace \ + --set installCRDs=true \ + --set serviceAccount.create=true \ + --set serviceAccount.name=external-secrets \ + --wait --timeout 5m + +forgejo-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (interactive) + tools/forgejo-openbao-eso-token-apply.sh + +forgejo-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-forgejo ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-forgejo.clustersecretstore.yaml" + +forgejo-mailer-es-deploy: check-railiance01-kubeconfig forgejo-openbao-store-deploy ## Apply Forgejo mailer ExternalSecret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_MAILER_EXTERNAL_SECRET) + +forgejo-mailer-es-status: check-railiance01-kubeconfig ## Show Forgejo mailer ExternalSecret sync status + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get externalsecret,secret -n $(FORGEJO_NAMESPACE) forgejo-mailer + +forgejo-dry-run: check-railiance01-kubeconfig ## helm template render for Forgejo + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm template $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) + +forgejo-server-dry-run: check-railiance01-kubeconfig ## Helm server dry-run Forgejo upgrade + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --dry-run=server --timeout 5m + +forgejo-secret-scan-template-deploy: check-railiance01-kubeconfig ## Install the immutable, non-secret receive-hook template + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply --server-side -f $(FORGEJO_SECRET_SCAN_TEMPLATE) + +forgejo-deploy: check-railiance01-kubeconfig forgejo-mailer-es-deploy forgejo-secret-scan-template-deploy ## Deploy / upgrade Forgejo on railiance01 + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-mailer -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-mailer in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Ensure ExternalSecret is Ready and OpenBao role external-secrets-forgejo is configured." >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm repo add gitea-charts https://dl.gitea.com/charts/ --force-update + KUBECONFIG="$(FORGEJO_KUBECONFIG)" helm upgrade --install $(FORGEJO_RELEASE) $(FORGEJO_CHART) \ + --version $(FORGEJO_CHART_VERSION) \ + --namespace $(FORGEJO_NAMESPACE) --create-namespace \ + -f $(FORGEJO_VALUES) \ + -f $(FORGEJO_REGISTRY_VALUES) \ + -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ + --wait --timeout 10m + +forgejo-ingress-deploy: check-railiance01-kubeconfig ## Apply Forgejo HTTPS ingress + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_INGRESS) + +forgejo-ssh-nodeport-deploy: check-railiance01-kubeconfig ## Apply Forgejo SSH NodePort (30022) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_SSH_NODEPORT) + +forgejo-status: check-railiance01-kubeconfig ## Show Forgejo pods, svc, ingress, cert, database + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get pods,svc,ingress,certificate,pvc -n $(FORGEJO_NAMESPACE) --ignore-not-found + @if KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) >/dev/null 2>&1; then \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl cnpg status $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE); \ + else \ + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get cluster $(FORGEJO_DB_CLUSTER) -n $(FORGEJO_DB_NAMESPACE) --ignore-not-found; \ + fi + +forgejo-smoke: ## Verify Forgejo web and OCI registry challenge + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-smoke.sh + +forgejo-npm-smoke: ## T07 npm install smoke (@coulomb/forgejo-npm-probe) + tools/forgejo-npm-smoke.sh + +forgejo-verify: check-railiance01-kubeconfig ## T05 acceptance: smoke, TLS, DB, mailer ESO, SSH operator, SOPS sentinels + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-verify.sh + +forgejo-operator-bootstrap: ## Idempotent operator user + SSH key bootstrap (no secrets in Git) + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-operator-bootstrap.sh + +forgejo-breakglass-seal: check-railiance01-kubeconfig ## Founder: rotate break-glass admin password into OpenBao (interactive, production-touching) + FORGEJO_KUBECONFIG="$(FORGEJO_KUBECONFIG)" FORGEJO_NAMESPACE="$(FORGEJO_NAMESPACE)" tools/forgejo-breakglass-seal.sh + +forgejo-breakglass-verify: ## Founder: verify one break-glass sign-in and write value-free evidence + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-breakglass-verify.sh + +forgejo-runner-registration-sops-bootstrap: check-railiance01-kubeconfig ## Capture cluster runner token into SOPS (operator age key) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-runner-registration-sops-bootstrap.sh + +forgejo-secrets-check: ## Verify Forgejo SOPS sentinels exist and are encrypted + @test -f $(FORGEJO_SECRETS) && grep -q '^sops:' $(FORGEJO_SECRETS) || { echo "Missing encrypted $(FORGEJO_SECRETS)" >&2; exit 1; } + @if [ -f $(FORGEJO_RUNNER_REGISTRATION_SECRET) ]; then \ + grep -q '^sops:' $(FORGEJO_RUNNER_REGISTRATION_SECRET) || { echo "Runner registration not SOPS-encrypted" >&2; exit 1; }; \ + else \ + echo "WARN: $(FORGEJO_RUNNER_REGISTRATION_SECRET) missing — run forgejo-runner-registration-sops-bootstrap on operator host" >&2; \ + fi + @echo "ok: Forgejo SOPS sentinels" + +forgejo-logs: check-railiance01-kubeconfig ## Tail Forgejo application logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/instance=$(FORGEJO_RELEASE) -f --tail=50 + +forgejo-runner-registration-deploy: check-railiance01-kubeconfig check-sops ## Apply SOPS runner registration secret + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f <(sops -d $(FORGEJO_RUNNER_REGISTRATION_SECRET)) + +forgejo-runner-deploy: check-railiance01-kubeconfig ## Deploy in-cluster Forgejo Actions runner (ADR-004) + @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-runner-registration -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ + echo "Missing secret forgejo-runner-registration in $(FORGEJO_NAMESPACE)." >&2; \ + echo "Apply helm/forgejo-runner-registration.sops.yaml or bootstrap once:" >&2; \ + echo " kubectl create secret generic forgejo-runner-registration -n forgejo --from-literal=token=" >&2; \ + exit 1; \ + } + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply -f $(FORGEJO_RUNNER_MANIFEST) + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl rollout status deployment/forgejo-runner -n $(FORGEJO_NAMESPACE) --timeout=5m + +forgejo-runner-status: check-railiance01-kubeconfig ## Show in-cluster runner pod and PVC state + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get deploy,pods,pvc -n $(FORGEJO_NAMESPACE) -l app.kubernetes.io/name=forgejo-runner --ignore-not-found + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner --tail=20 2>/dev/null || true + +forgejo-runner-logs: check-railiance01-kubeconfig ## Tail in-cluster runner logs + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl logs -n $(FORGEJO_NAMESPACE) deploy/forgejo-runner -c runner -f --tail=50 + +##@ reuse-surface (reuse.coulomb.social) + +reuse-dry-run: check-railiance01-kubeconfig ## helm template render (no apply) for reuse-surface + KUBECONFIG="$(REUSE_KUBECONFIG)" helm template $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) \ + -f $(REUSE_VALUES) + +reuse-deploy: check-railiance01-kubeconfig ## Deploy / upgrade reuse-surface Helm release on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" helm upgrade --install $(REUSE_RELEASE) $(REUSE_CHART) \ + --namespace $(REUSE_NAMESPACE) --create-namespace \ + -f $(REUSE_VALUES) --wait --timeout 5m + +reuse-status: check-railiance01-kubeconfig ## Show reuse-surface pod / svc / ingress / cert state on Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get pods,svc,ingress,pvc,certificate -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) --ignore-not-found + +reuse-smoke: check-railiance01-kubeconfig ## Verify reuse public health, repo list, and TLS certificate readiness + curl -fsS "$(REUSE_URL)/health" + curl -fsS "$(REUSE_URL)/v1/repos" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl wait -n $(REUSE_NAMESPACE) --for=condition=Ready certificate/$(REUSE_CERTIFICATE) --timeout=30s + +reuse-logs: check-railiance01-kubeconfig ## Tail reuse-surface service logs from Railiance01 + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl logs -n $(REUSE_NAMESPACE) -l app.kubernetes.io/instance=$(REUSE_RELEASE) -f --tail=50 + +reuse-forgejo-webhook: check-railiance01-kubeconfig ## Idempotent org webhook on Forgejo -> reuse hub (REUSE-WP-0019-T03) + bash tools/reuse-forgejo-webhook.sh + +reuse-openbao-eso-token-apply: ## Mint coulombcore OpenBao ESO token + Secret on railiance01 (CCR-2026-0005) + OPENBAO_TOKEN_FILE="$(HOME)/.local/openbao/platform-admin.token" bash tools/reuse-openbao-eso-token-apply.sh + +reuse-openbao-store-deploy: check-railiance01-kubeconfig eso-deploy ## Apply openbao-reuse ClusterSecretStore + test -d "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore" + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f "$(PLATFORM_REPO)/argocd/platform-addons/openbao-secretstore/openbao-reuse.clustersecretstore.yaml" + +reuse-runtime-es-deploy: check-railiance01-kubeconfig reuse-openbao-store-deploy ## Apply reuse-surface runtime ExternalSecret + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl apply -f $(REUSE_RUNTIME_EXTERNAL_SECRET) + +reuse-runtime-es-status: check-railiance01-kubeconfig ## Show reuse-surface runtime ExternalSecret sync status + KUBECONFIG="$(REUSE_KUBECONFIG)" kubectl get externalsecret,secret -n $(REUSE_NAMESPACE) reuse-surface-runtime reuse-surface-env + +reuse-webhook-smoke: check-railiance01-kubeconfig ## Webhook + ESO + federated smoke (RAILIANCE-WP-0011-T04) + bash tools/reuse-webhook-smoke.sh + +##@ policy-nexus (policy.coulomb.social) + +policy-nexus-check-binding: ## Require a source-controlled, immutable production binding + @test -d "$(POLICY_NEXUS_RAPP)" || { echo "Missing sibling rApp repo: $(POLICY_NEXUS_RAPP)" >&2; exit 2; } + python3 tools/check-policy-nexus-binding.py "$(POLICY_NEXUS_BINDING)" + +policy-nexus-check-image: policy-nexus-check-binding ## Verify the bound OCI digest exists in Forgejo + tools/check-oci-image.sh "$(POLICY_NEXUS_IMAGE_REPOSITORY)@$(POLICY_NEXUS_IMAGE_DIGEST)" + +policy-nexus-render: policy-nexus-check-binding ## Render the rApp chart with the approved production binding + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" render \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-server-dry-run: policy-nexus-check-binding ## Validate the approved policy package against Railiance01 + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" server-dry-run TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-deploy: policy-nexus-check-binding ## Deploy the approved policy release through its rApp package + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" deploy TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-status: ## Show the policy-nexus Helm release state + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" status TARGET="$(POLICY_NEXUS_TARGET)" + +policy-nexus-smoke: policy-nexus-check-binding ## Verify public content, TLS, and both immutable release identities + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" verify-live TARGET="$(POLICY_NEXUS_TARGET)" \ + IMAGE_REPOSITORY="$(POLICY_NEXUS_IMAGE_REPOSITORY)" \ + IMAGE_DIGEST="$(POLICY_NEXUS_IMAGE_DIGEST)" \ + PUBLICATION_MANIFEST_DIGEST="$(POLICY_NEXUS_PUBLICATION_DIGEST)" \ + SOURCE_INVENTORY_DIGEST="$(POLICY_NEXUS_SOURCE_INVENTORY_DIGEST)" \ + SOURCE_SET_DIGEST="$(POLICY_NEXUS_SOURCE_SET_DIGEST)" + +policy-nexus-rollback: ## Roll back to a named previous Helm revision + @test -n "$(POLICY_NEXUS_REVISION)" || { echo "POLICY_NEXUS_REVISION is required" >&2; exit 2; } + $(MAKE) -C "$(POLICY_NEXUS_RAPP)" rollback TARGET="$(POLICY_NEXUS_TARGET)" REVISION="$(POLICY_NEXUS_REVISION)" + +##@ Help + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} \ + /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) + +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + +.PHONY: forgejo-secret-scan-template-deploy + + +##@ Isolated Forgejo source release preparation (no deployment/publication) +FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release +FORGEJO_SOURCE_ARCHIVE ?= +FORGEJO_GO_ARCHIVE ?= +FORGEJO_NODE_ARCHIVE ?= +FORGEJO_BUILD_GOMODCACHE ?= +FORGEJO_BUILD_GOCACHE_SEED ?= +FORGEJO_BUILD_NPM_CACHE_SEED ?= + +.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container +forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace + /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") + +forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts + /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets + /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline + /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy + /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" + diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/tools/forgejo-release-build.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/tools/forgejo-release-build.py new file mode 100644 index 00000000..08524d2a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/owner-source/tools/forgejo-release-build.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build-only public Forgejo owner recipe; no deployment or publication code.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time +import math +import datetime + +OWNER=Path(__file__).resolve().parents[1] +INPUTS=OWNER/'build/forgejo' +LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) + +def digest(path): + h=hashlib.sha256() + with Path(path).open('rb') as f: + for block in iter(lambda:f.read(1024*1024),b''):h.update(block) + return h.hexdigest() + +def save(path,data): + Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') + +def check(path,expected): + if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) + +def base_env(work): + return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', + 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', + 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], + 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', + 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), + 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', + 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', + 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), + 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', + 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', + 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} + +def sandbox(work,env): + cache=Path(env['GOMODCACHE']) + return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', + '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), + '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] + +def run(work,name,args,env=None,offline=False,cwd=None): + actual=sandbox(work,env)+args if offline else args + start=time.time() + with (work/(name+'.log')).open('w') as out: + proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) + save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) + code=proc.wait() + result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} + save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) + print(json.dumps(result),flush=True) + if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) + return result + +def checked_inputs(work): + for n,h in LOCK['host_tools'].items():check(n,h) + for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) + for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) + +def prepare(a,work): + if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') + for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: + if not getattr(a,n):raise ValueError('explicit '+n+' required') + check(getattr(a,n),LOCK[key]) + if not a.module_cache:raise ValueError('explicit public module cache required') + cache=Path(a.module_cache).resolve();assert cache.is_dir() + check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + for n,h in LOCK['host_tools'].items():check(n,h) + work.mkdir(parents=True);(work/'home').mkdir() + with tarfile.open(a.source_archive) as t: + # The official archive is digest-bound before any member is extracted. + parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') + folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() + for n in ['go_archive','node_archive']: + with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') + for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') + if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') + if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) + checked_inputs(work) + packages=json.loads((work/'source/package-lock.json').read_text())['packages'] + for name,package in packages.items(): + if not name:continue + if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): + raise ValueError('non-public/non-integrity-bound npm package: '+name) + print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) + +def normalize(folder): + epoch=LOCK['source_date_epoch'] + for root,dirs,files in os.walk(folder): + dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] + for n in files: + p=Path(root)/n + if not p.is_symlink():os.utime(p,(epoch,epoch)) + os.utime(root,(epoch,epoch)) + +def build(work): + checked_inputs(work);env=base_env(work) + run(work,'verify-go-modules',['go','mod','verify'],env,True) + run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) + normalize(work/'source') + version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] + run(work,'frontend',['make']+version+['frontend'],env,True) + for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) + run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) + checked_inputs(work) + save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) + +def verify(work): + checked_inputs(work);binary=work/'source/gitea' + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + target.mkdir() + # No source, asset tree, host /usr, libraries, configuration or HOME is + # exposed to the executable. A filesystem fallback cannot pass this proof. + bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', + '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] + for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', + 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', + 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): + bare+=['--setenv',k,v] + bare+=['--','/app/forgejo'] + outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} + run(work,'binary-version',bare+['--version'],outer) + run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) + records={} + for folder in ['public','options','templates']: + for f in sorted((work/'source'/folder).rglob('*')): + if not f.is_file():continue + n=str(f.relative_to(work/'source'));q=target/n + if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) + records[n]=digest(f) + assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records + assert 'public/assets/licenses.txt' in records + save(work/'embedded-files.json',records) + readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) + assert 'INTERP' not in readelf.stdout + save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), + 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, + 'no_source_assets_libraries_or_host_config_mounted':True, + 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', + 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) + print((work/'verification.json').read_text()) + +def container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + context=work/'container-context' + if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') + archive_path=work/'image.oci.tar' + if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') + base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() + assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] + original=json.loads(base_bytes) + epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) + context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') + os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) + (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') + os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) + config=work/'docker-config';config.mkdir(exist_ok=True) + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} + # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. + # OCI output permits layer rewriting without the Docker-store unpack conflict. + # The epoch never predates the immutable base, preserving its exact layer prefix. + run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', + '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) + metadata=json.loads((work/'image-build-metadata.json').read_text()) + with tarfile.open(archive_path) as archive: + index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) + assert len(index['manifests'])==1 + descriptor=index['manifests'][0] + assert descriptor['platform']=={'architecture':'amd64','os':'linux'} + assert descriptor['digest']==metadata['containerimage.digest'] + def blob(d): + value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() + assert 'sha256:'+hashlib.sha256(value).hexdigest()==d + return value + manifest=json.loads(blob(descriptor['digest'])) + config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) + assert config_digest==metadata['containerimage.config.digest'] + for layer in manifest['layers']:blob(layer['digest']) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + assert config['architecture']=='amd64' and config['os']=='linux' + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + labels=dict(original['config'].get('Labels',{})) + labels.update({'org.opencontainers.image.version':LOCK['release_version'], + 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) + assert config['config'].get('Labels')==labels + assert config['config']['User']=='1000:1000' + expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') + assert config['created']==expected_created + found=False + last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] + with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: + for member in layer: + name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') + if name=='app/gitea/gitea': + h=hashlib.sha256();stream=layer.extractfile(member) + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) + assert h.hexdigest()==verified['binary_sha256'];found=True + assert found + save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, + 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), + 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, + 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, + 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, + 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) + print((work/'container-result.json').read_text()) + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) + parser.add_argument('--workspace',required=True) + for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) + a=parser.parse_args();work=Path(a.workspace).resolve() + if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') + if a.phase=='prepare':return prepare(a,work) + assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') + if a.phase=='acquire-npm': + checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' + return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') + {'build':build,'verify':verify,'container':container}[a.phase](work) + +if __name__=='__main__':main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/README.md b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/README.md new file mode 100644 index 00000000..c6d19b95 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/README.md @@ -0,0 +1,138 @@ +# Actual Forgejo source-state transaction primitive, r1 + +This is bounded source progress for NK-WP-0048-T34 / RAPPS-WP-0016-T05/T08, +not an admitted operational backend or completed inverse. It adds real database +primitives to the actual reviewed Forgejo 11.0.3 source under `/tmp`; it adds no +second application source repository in Apps. No production/native database, +configuration, credential, deployment or identity was read or changed. Existing +frozen extension and release packets remain unchanged. No canonical owner edit, +publication, installed image selection or native dispatch occurred. + +## Implemented database mechanics + +`models/auth/source_operation.go` operates on actual `login_source` rows through +Forgejo's real transaction machinery. Prepare commits original whole source +inventory and exact private desired configuration before a source mutation. +Apply checks exact current/original equality, modifies one explicitly identified +OAuth2 source, verifies the complete expected persisted post-state and commits +post-state plus outcome atomically. Existing targets require exact ID/name/type; +case creation requires name absence. Configuration decoding is deliberately +avoided for snapshots and restoration, so unknown fields cannot silently vanish. +Complete raw private/public equality, source ID and created/updated identity +stay inside owner custody. Public results contain only operation ID, phase and +numeric source ID; no private digest or configuration is returned. + +Inverse independently checks complete current/post equality. It restores +preexisting sources in place with the exact original raw bytes and ID. It never +deletes a preexisting or unchanged source. Only confirmed case creation can +select deletion; actual local user and external-login references refuse deletion +without changing those users/links. Another unresolved managed operation on the +same target refuses, rather than sharing creation provenance. Unknown or changed +state refuses. A prepared intent can be cancelled without changing sources. +Reconcile reads committed phase/current equality without retrying mutation. +Errors provide no driver/configuration text and return no successful result. + +SQLite acquires a write transaction before source reads, excluding even +uncooperative SQL writers. The actual two-connection exclusion test sets the +managed engine to DEFERRED mode, so it exercises the explicit initial write. +The source contains PostgreSQL table locks covering source/journal/user/link +writers; PostgreSQL behavior is **not tested or accepted by this SQLite proof**. +MySQL and nested transactions refuse. Locks span each database transaction; +they do not span an entire release or control provider registration in another +process, DDL/admin bypasses, Helm/image writers or unmanaged shared consumers. +Complete application/release writer exclusion still needs owner implementation. + +The production DB hooks currently print arguments on slow/error queries and +runtime traces, even with normal SQL logging disabled. This patch adds an +explicit private-query context and redacts statement, arguments and driver-error +text in those three hooks. The primitive refuses both configured SQL logging +and the actual XORM logger's argument logging. Server/driver/audit/backup logging, +custom hooks and private-runtime output custody remain owner review obligations. +The actual hook tests include positive unredacted controls; no real secrets +were used. The initial candidate only wrapped the Go context; inspection found +that existing Engined contexts and db.WithTx did not propagate that marker into +XORM's real query hooks. Direct hook fixtures alone missed this flaw. The final +patch rebinds marked contexts in db.GetEngine, preserving the same transaction +and actual SQL cancellation context; ordinary unmarked routing is unchanged. +The added actual hooked-XORM failing-query test checks real slow/error/trace +paths and positive/private controls. Earlier results remain in the evidence as +insufficient for this propagation question. + +## Private journal custody and recovery limits + +`login_source_operation` is registered with Forgejo's existing model/schema sync. +Its before/desired/post fields contain **private raw configurations and secret +copies**, including unrelated-source snapshots. It is not a value-free receipt, +independent authenticated evidence, an authorization token or an audit outbox. +It inherits the owner's database/storage/backup custody and encryption limits; +this patch adds no encryption or new read grant. Existing database access does +not automatically authorize an additional journal reader. Owner credential +inventory, recovery access, backup inclusion, retention and eventual deletion +must explicitly account for these additional copies before admission. + +Preparation refuses at 64 unresolved or 256 total journals, at a desired config +above 64 KiB or an original inventory above 1 MiB. These are refusal bounds, not +retention approval. Nothing automatically erases unresolved, failed/uncertain or +terminal private recovery state. A separately authenticated owner retention and +purge decision/implementation is still required; exhaustion remains refused. +Completed rows preserve original/desired/post state for recovery. Individual +refused or storage-failed calls are not a separately durable attempt log: the +admitted runtime must preserve their original errors/uncertainty, current-session +references and authenticated attempt/evidence history outside a rolled-back +transaction. DB commit uncertainty always calls reconciliation; an ambiguous +response does not justify blindly replaying the effect. + +The actual file-backed SQLite test kills a child process immediately after the +real ApplySourceOperation INSERT, before post-state/journal update/commit. A +new process sees the original prepared journal and no source. Later independent +processes observe confirmed apply, perform inverse and confirm reverted absence. +This proves bounded actual local database crash/restart mechanics, not an +external session, deployed database or independent evidence facility. + +## Required integration before any exposed operation + +There is intentionally no new unauthenticated CLI, API, privileged default +factory or always-true authority callback. These exported low-level functions +have the same private-database trust boundary as existing CRUD; they are not an +authorization boundary. An existing owner CLI/service must authenticate a fresh +current initiating principal/session and exact Access Engine decision **before +every call**, including preparation/reconciliation journal writes and each +inverse attempt. Admission must bind case, operation/transaction, source and +accepted implementation revision, exact parameters, target, principal, current +session, purpose/action and expiry/revocation. Neither an arbitrary Subject in +a CheckRequest, a service caller token, an offline config pin nor this journal +authenticates the initiating human/agent. Fresh inverse authority cannot reuse +a revoked hand-over session. All exit paths still need revocation. + +The shared producer-owned Access Engine adapter is separate coordinator work. +The real owner session/evidence/outbox/revocation facilities, release-wide +exclusion, cross-process provider refresh/restart and session-to-CLI binding +remain unresolved here. These are existing hand-over/inverse obligations, +not an eighth SecurityCase kernel step or another service. Actual app policy +and exact desired-config validation remain the admitted owner's responsibility. + +An image downgrade remains refused until authoritative source restoration/removal, +complete unrelated-state checks and all extension-sensitive configurations are +independently verified safe. Even a restored original source can contain a +preexisting extension gate; a source inverse does not itself authorize downgrade. +Helm omission/rollback never substitutes for source inverse. Keep the extended +image and gate when linked-source deletion refuses or any current state is +uncertain; preserve local sign-in and reconcile with the owner. Existing users +or external links must never be removed to force deletion. + +## Evidence + +The exact eight-path additive patch, actual source pre/postimages, inventories, +bounded real package tests, crash/exclusion tests and original failed attempts +are retained in this directory. Tests run in network-disabled bwrap with only +public source/toolchain/module inputs; toolchain/modules are read-only and build +cache/source are isolated writable copies. `run-tests.py` records the exact +sandbox and argv. Only synthetic fixtures are used. Initial Git-global-config +and hook-test pointer-method harness failures remain evidence, not successes. +Early test sandboxes used network isolation without a PID namespace; their +host-proc visibility is retained as a profile limitation, not an isolation proof. +No host process-argument scan was authored. Final and corrected control runs use +both network and PID isolation before mounting proc. The first control trial +started before its separate source copy completed; its missing-package setup +failure is explicitly invalid, preserved and superseded, never a detected guard. +No lifecycle, native gate or owner workplan is closed by these source tests. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt1.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt1.json new file mode 100644 index 00000000..e5565ef5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt1.json @@ -0,0 +1,68 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "-run", + "TestSourceOperation", + "-count=1", + "-v" + ], + "returncode": 1, + "seconds": 30.90784502029419 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt2.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt2.json new file mode 100644 index 00000000..2c7be809 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt2.json @@ -0,0 +1,68 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "-run", + "TestSourceOperation", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 16.431792497634888 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt3.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt3.json new file mode 100644 index 00000000..be4bb4aa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt3.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 1, + "seconds": 24.786890029907227 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt4.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt4.json new file mode 100644 index 00000000..7f5eb1e5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt4.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 92.08943581581116 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt5.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt5.json new file mode 100644 index 00000000..44743215 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt5.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 83.88910269737244 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt6.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt6.json new file mode 100644 index 00000000..f45c1450 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt6.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 30.82476782798767 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt7-net-only.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt7-net-only.json new file mode 100644 index 00000000..4d1d0376 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/apps-operation-tests-attempt7-net-only.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 213.70628547668457 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/final-package-tests.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/final-package-tests.json new file mode 100644 index 00000000..2270eaf1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/evidence/final-package-tests.json @@ -0,0 +1,70 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--unshare-pid", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--bind", + "/tmp/apps-operation-backend-r1", + "/tmp/apps-operation-backend-r1", + "--bind", + "/tmp/apps-operation-gocache-r1", + "/tmp/apps-operation-gocache-r1", + "--chdir", + "/tmp/apps-operation-backend-r1", + "--", + "env", + "-i", + "PATH=/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/operation-home", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-operation-gocache-r1", + "GOPATH=/tmp/operation-gopath", + "GOVCS=*:off", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/tmp/operation-test.gitconfig", + "GIT_TERMINAL_PROMPT=0", + "GOMAXPROCS=4", + "CGO_ENABLED=1", + "go", + "test", + "-p", + "4", + "-tags", + "sqlite sqlite_unlock_notify", + "./models/auth", + "./models/db", + "-run", + "TestSourceOperation|TestSensitiveSQLHooks", + "-count=1", + "-v" + ], + "returncode": 0, + "seconds": 46.296929121017456 +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-operation-source.patch b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-operation-source.patch new file mode 100644 index 00000000..52290a4a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-operation-source.patch @@ -0,0 +1,1049 @@ +--- /dev/null ++++ b/models/auth/source_operation.go +@@ -0,0 +1,430 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package auth ++ ++// This owner-confined database primitive is NOT an authorization boundary. ++// No CLI/web/API route exposes it. The admitted runtime must separately obtain ++// fresh authenticated apply/inverse sessions and retain signed public evidence. ++// Configurations and private journal rows must never leave database custody. ++ ++import ( ++ "context" ++ "encoding/json" ++ "errors" ++ "reflect" ++ "regexp" ++ "sort" ++ ++ "forgejo.org/models/db" ++ "forgejo.org/modules/setting" ++ "forgejo.org/modules/timeutil" ++ "xorm.io/xorm" ++) ++ ++var ( ++ ErrSourceOperationConflict = errors.New("source operation requires owner reconciliation") ++ ErrSourceOperationStorage = errors.New("source operation storage failure; reconcile before retry") ++ ErrSourceOperationUnsupported = errors.New("source operation requires standalone SQLite/PostgreSQL transaction with SQL logging disabled") ++ operationIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{7,127}$`) ++) ++ ++// Private source rows deliberately bypass Config decoding: decoding and ++// reserializing can erase unknown fields, including a gate on an older image. ++type sourceOperationRow struct { ++ ID int64 `xorm:"pk autoincr"` ++ Type Type ++ Name string ++ IsActive bool ++ IsSyncEnabled bool ++ Cfg string `xorm:"TEXT"` ++ CreatedUnix timeutil.TimeStamp ++ UpdatedUnix timeutil.TimeStamp ++} ++ ++func (sourceOperationRow) TableName() string { return "login_source" } ++ ++// The whole inventory, including private configs, stays in the owner's database. ++// Neither journal payload nor its digest is a public result. ++type sourceOperationJournal struct { ++ OperationID string `xorm:"pk VARCHAR(128)"` ++ CaseID string `xorm:"VARCHAR(128)"` ++ TargetName string ++ TargetID int64 ++ Phase string ++ BeforeState string `xorm:"TEXT"` ++ DesiredState string `xorm:"TEXT"` ++ PostState string `xorm:"TEXT"` ++} ++ ++func (sourceOperationJournal) TableName() string { return "login_source_operation" } ++func init() { db.RegisterModel(new(sourceOperationJournal)) } ++ ++// SourceOperationResult contains only public outcome metadata. An authenticated ++// session/evidence producer must bind this result to its actual principal, ++// admitted source revision, exact parameters and current operation session. ++type SourceOperationResult struct { ++ OperationID string ++ Phase string ++ SourceID int64 ++} ++ ++// sourceOperationTx acquires a database-enforced lock before any source read. ++// SQLite's first write serializes every database writer, including stock admin ++// CRUD. PostgreSQL table locks exclude ordinary SQL source and user-link writes. ++// Privileged DDL, process/provider refresh and release writers are outside this ++// bounded primitive; they still require owner runtime/release exclusion. ++func sourceOperationTx(ctx context.Context, f func(context.Context) error) error { ++ if db.InTransaction(ctx) || setting.Database.LogSQL || (!setting.Database.Type.IsSQLite3() && !setting.Database.Type.IsPostgreSQL()) { ++ return ErrSourceOperationUnsupported ++ } ++ err := db.WithTx(db.WithSensitiveSQL(ctx), func(tx context.Context) error { ++ session, ok := db.GetEngine(tx).(*xorm.Session) ++ if !ok || session.Engine().Logger().IsShowSQL() { ++ return ErrSourceOperationUnsupported ++ } ++ if setting.Database.Type.IsPostgreSQL() { ++ if _, err := db.GetEngine(tx).Exec(`LOCK TABLE login_source_operation, login_source, "user", external_login_user IN EXCLUSIVE MODE`); err != nil { ++ return err ++ } ++ } else { ++ // Even with no matching row, UPDATE starts SQLite's write transaction. ++ if _, err := db.GetEngine(tx).Exec(`UPDATE login_source_operation SET phase = phase WHERE operation_id = ''`); err != nil { ++ return err ++ } ++ } ++ return f(tx) ++ }) ++ if err == nil || errors.Is(err, ErrSourceOperationConflict) || errors.Is(err, ErrSourceOperationUnsupported) || IsErrSourceInUse(err) { ++ return err ++ } ++ return ErrSourceOperationStorage ++} ++ ++func sourceInventory(ctx context.Context) ([]sourceOperationRow, error) { ++ rows := make([]sourceOperationRow, 0) ++ err := db.GetEngine(ctx).OrderBy("id").Find(&rows) ++ return rows, err ++} ++func encodeSourceInventory(rows []sourceOperationRow) string { ++ b, _ := json.Marshal(rows) ++ return string(b) ++} ++func decodeSourceInventory(s string) ([]sourceOperationRow, error) { ++ var rows []sourceOperationRow ++ if err := json.Unmarshal([]byte(s), &rows); err != nil { ++ return nil, ErrSourceOperationConflict ++ } ++ return rows, nil ++} ++func sourceJournal(ctx context.Context, operationID string) (*sourceOperationJournal, error) { ++ row := new(sourceOperationJournal) ++ found, err := db.GetEngine(ctx).ID(operationID).Get(row) ++ if err != nil { ++ return nil, err ++ } ++ if !found { ++ return nil, ErrSourceOperationConflict ++ } ++ return row, nil ++} ++func journalResult(j *sourceOperationJournal) SourceOperationResult { ++ return SourceOperationResult{OperationID: j.OperationID, Phase: j.Phase, SourceID: j.TargetID} ++} ++ ++// PrepareSourceOperation durably captures exact source identity and complete ++// original inventory before mutation. desired is private owner input, not an ++// authorization token. targetID=0 requires authoritative name absence. Existing ++// sources require both exact ID and name. Reuse of an operation ID is refused. ++func PrepareSourceOperation(ctx context.Context, operationID, caseID string, targetID int64, desired *Source) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ if !operationIDPattern.MatchString(operationID) || !operationIDPattern.MatchString(caseID) || desired == nil || desired.Type != OAuth2 || desired.Name == "" || desired.Cfg == nil || desired.IsSyncEnabled || desired.ID != targetID { ++ return result, ErrSourceOperationConflict ++ } ++ cfg, err := desired.Cfg.ToDB() ++ if err != nil || len(cfg) > 64*1024 || !json.Valid(cfg) { ++ return result, ErrSourceOperationConflict ++ } ++ candidate := sourceOperationRow{ID: targetID, Type: OAuth2, Name: desired.Name, IsActive: desired.IsActive, Cfg: string(cfg)} ++ candidateBytes, _ := json.Marshal(candidate) ++ err = sourceOperationTx(ctx, func(tx context.Context) error { ++ // Bound confidential duplicate custody. Refuse rather than erase journals ++ // with pending recovery or silently expire terminal private snapshots. ++ count, err := db.GetEngine(tx).Count(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if count >= 256 { ++ return ErrSourceOperationConflict ++ } ++ active, err := db.GetEngine(tx).Where("phase IN (?, ?, ?)", "prepared", "applied", "unchanged").Count(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if active >= 64 { ++ return ErrSourceOperationConflict ++ } ++ found, err := db.GetEngine(tx).ID(operationID).Exist(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if found { ++ return ErrSourceOperationConflict ++ } ++ shared, err := db.GetEngine(tx).Where("target_name = ? AND phase IN (?, ?, ?)", desired.Name, "prepared", "applied", "unchanged").Exist(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if shared { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if len(encodeSourceInventory(rows)) > 1024*1024 { ++ return ErrSourceOperationConflict ++ } ++ matched := false ++ for _, row := range rows { ++ if row.Name == desired.Name || row.ID == targetID { ++ if targetID == 0 || row.ID != targetID || row.Name != desired.Name || row.Type != OAuth2 { ++ return ErrSourceOperationConflict ++ } ++ matched = true ++ } ++ } ++ if targetID != 0 && !matched { ++ return ErrSourceOperationConflict ++ } ++ j := &sourceOperationJournal{OperationID: operationID, CaseID: caseID, TargetName: desired.Name, TargetID: targetID, Phase: "prepared", BeforeState: encodeSourceInventory(rows), DesiredState: string(candidateBytes)} ++ if _, err := db.GetEngine(tx).Insert(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// ApplySourceOperation atomically compares every original raw source row, ++// mutates exactly the target and persists confirmed post-state. A lost commit ++// acknowledgement is reconciled using the same operation ID, never a fresh ID. ++// No source registration/network call occurs inside the database transaction. ++func ApplySourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if j.Phase == "applied" || j.Phase == "unchanged" { ++ if encodeSourceInventory(rows) != j.PostState { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ } ++ if j.Phase != "prepared" || encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ var desired sourceOperationRow ++ if json.Unmarshal([]byte(j.DesiredState), &desired) != nil { ++ return ErrSourceOperationConflict ++ } ++ unchanged := false ++ for _, row := range rows { ++ if row.ID == j.TargetID { ++ desired.CreatedUnix = row.CreatedUnix ++ desired.UpdatedUnix = row.UpdatedUnix ++ unchanged = reflect.DeepEqual(row, desired) ++ } ++ } ++ if !unchanged { ++ desired.UpdatedUnix = timeutil.TimeStampNow() ++ if j.TargetID == 0 { ++ desired.CreatedUnix = desired.UpdatedUnix ++ if _, err := db.GetEngine(tx).NoAutoTime().Insert(&desired); err != nil { ++ return err ++ } ++ j.TargetID = desired.ID ++ } else { ++ n, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(&desired) ++ if err != nil { ++ return err ++ } ++ if n != 1 { ++ return ErrSourceOperationConflict ++ } ++ } ++ } ++ post, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ expected := append([]sourceOperationRow{}, rows...) ++ if !unchanged { ++ replaced := false ++ for i := range expected { ++ if expected[i].ID == desired.ID { ++ expected[i] = desired ++ replaced = true ++ } ++ } ++ if !replaced { ++ expected = append(expected, desired) ++ } ++ sort.Slice(expected, func(i, k int) bool { return expected[i].ID < expected[k].ID }) ++ } ++ if !reflect.DeepEqual(post, expected) { ++ return ErrSourceOperationConflict ++ } ++ j.PostState = encodeSourceInventory(post) ++ j.Phase = "applied" ++ if unchanged { ++ j.Phase = "unchanged" ++ } ++ if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// InverseSourceOperation requires fresh external inverse authority on EACH call. ++// It restores a preexisting source in place; only confirmed case creation can ++// delete. Any linked local/external account refuses deletion without alteration. ++func InverseSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if j.Phase == "reverted" || j.Phase == "cancelled" { ++ if encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ } ++ if j.Phase == "prepared" { ++ if encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ j.Phase = "cancelled" ++ } else { ++ if (j.Phase != "applied" && j.Phase != "unchanged") || encodeSourceInventory(rows) != j.PostState { ++ return ErrSourceOperationConflict ++ } ++ before, err := decodeSourceInventory(j.BeforeState) ++ if err != nil { ++ return err ++ } ++ var original *sourceOperationRow ++ for i := range before { ++ if before[i].ID == j.TargetID { ++ original = &before[i] ++ } ++ } ++ if original != nil { ++ if _, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(original); err != nil { ++ return err ++ } ++ } else { ++ for _, table := range []struct{ name, column string }{{"user", "login_source"}, {"external_login_user", "login_source_id"}} { ++ count, err := db.GetEngine(tx).Table(table.name).Where(table.column+" = ?", j.TargetID).Count() ++ if err != nil { ++ return err ++ } ++ if count != 0 { ++ return ErrSourceInUse{ID: j.TargetID} ++ } ++ } ++ n, err := db.GetEngine(tx).ID(j.TargetID).Delete(new(sourceOperationRow)) ++ if err != nil { ++ return err ++ } ++ if n != 1 { ++ return ErrSourceOperationConflict ++ } ++ } ++ restored, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if encodeSourceInventory(restored) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ j.Phase = "reverted" ++ } ++ if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// ReconcileSourceOperation only checks committed journal/current equality. It ++// never infers that a prepared intent ran and never retries the mutation. The ++// separately authenticated runtime must call this after an ambiguous result. ++func ReconcileSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ expected := j.BeforeState ++ switch j.Phase { ++ case "prepared", "reverted", "cancelled": ++ case "applied", "unchanged": ++ expected = j.PostState ++ default: ++ return ErrSourceOperationConflict ++ } ++ if encodeSourceInventory(rows) != expected { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} +--- /dev/null ++++ b/models/auth/source_operation_test.go +@@ -0,0 +1,235 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth_test ++ ++import ( ++ "context" ++ "encoding/json" ++ "fmt" ++ "testing" ++ ++ auth "forgejo.org/models/auth" ++ "forgejo.org/models/db" ++ "forgejo.org/models/unittest" ++ "forgejo.org/modules/setting" ++ oauth2 "forgejo.org/services/auth/source/oauth2" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func operationDB(t *testing.T) context.Context { ++ t.Helper() ++ require.NoError(t, unittest.PrepareTestDatabase()) ++ _, err := db.GetEngine(db.DefaultContext).Exec("DELETE FROM login_source_operation") ++ require.NoError(t, err) ++ auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) ++ return db.DefaultContext ++} ++func operationSource() *auth.Source { ++ return &auth.Source{Type: auth.OAuth2, Name: "operation-test", IsActive: false, Cfg: &oauth2.Source{Provider: "openidConnect", ClientID: "synthetic-client", ClientSecret: "synthetic-private-original", RequiredClaimName: "groups", RequiredClaimValues: []string{"g1", "g2", "g3"}, Scopes: []string{"openid", "profile", "groups"}}} ++} ++func operationRows(t *testing.T, ctx context.Context) string { ++ t.Helper() ++ rows, err := db.GetEngine(ctx).Query("SELECT * FROM login_source ORDER BY id") ++ require.NoError(t, err) ++ b, err := json.Marshal(rows) ++ require.NoError(t, err) ++ return string(b) ++} ++func TestSourceOperationCreateAndInverse(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ r, err := auth.PrepareSourceOperation(ctx, "operation-create", "case-create", 0, operationSource()) ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ require.Positive(t, r.SourceID) ++ id := r.SourceID ++ post := operationRows(t, ctx) ++ r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, id, r.SourceID) ++ require.Equal(t, post, operationRows(t, ctx)) ++ _, err = auth.InverseSourceOperation(ctx, "operation-create", "wrong-case") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ r, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++ _, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++} ++func TestSourceOperationRestoresExactRawPreexisting(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ // The old/unknown field must survive restoration byte for byte, including the ++ // private synthetic secret. A typed OAuth2 decoder would silently lose it. ++ raw := `{"Provider":"openidConnect","ClientID":"synthetic-client","ClientSecret":"synthetic-private-original","FutureGuard":{"x":true}}` ++ _, err := db.GetEngine(ctx).Exec("UPDATE login_source SET cfg = ? WHERE id = ?", raw, s.ID) ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-new" ++ _, err = auth.PrepareSourceOperation(ctx, "operation-restore", "case-restore", s.ID, s) ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "operation-restore", "case-restore") ++ require.NoError(t, err) ++ _, err = auth.InverseSourceOperation(ctx, "operation-restore", "case-restore") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++ source, err := auth.GetSourceByID(ctx, s.ID) ++ require.NoError(t, err) ++ require.Equal(t, s.ID, source.ID) ++} ++func TestSourceOperationUnchangedNeverDeletesPreexisting(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-unchanged", "case-unchanged", s.ID, s) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-unchanged", "case-unchanged") ++ require.NoError(t, err) ++ require.Equal(t, "unchanged", r.Phase) ++ _, err = auth.InverseSourceOperation(ctx, "operation-unchanged", "case-unchanged") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++} ++func TestSourceOperationPrivateDriftRefuses(t *testing.T) { ++ for _, stage := range []string{"before-apply", "before-inverse"} { ++ t.Run(stage, func(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-private-drift", "case-private-drift", s.ID, s) ++ require.NoError(t, err) ++ if stage == "before-inverse" { ++ _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ require.NoError(t, err) ++ } ++ s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-drift" ++ require.NoError(t, auth.UpdateSource(ctx, s)) ++ drift := operationRows(t, ctx) ++ if stage == "before-apply" { ++ _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ } else { ++ _, err = auth.InverseSourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ } ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.NotContains(t, err.Error(), "synthetic-private") ++ require.Equal(t, drift, operationRows(t, ctx)) ++ }) ++ } ++} ++func TestSourceOperationStockCRUDDriftRefuses(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-other-writer", "case-other-writer", 0, operationSource()) ++ require.NoError(t, err) ++ other := operationSource() ++ other.Name = "unrelated-source" ++ require.NoError(t, auth.CreateSource(ctx, other)) ++ drift := operationRows(t, ctx) ++ _, err = auth.ApplySourceOperation(ctx, "operation-other-writer", "case-other-writer") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, drift, operationRows(t, ctx)) ++} ++func TestSourceOperationLinkedSourceRefusesDeletion(t *testing.T) { ++ for _, kind := range []string{"user", "external"} { ++ t.Run(kind, func(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-linked", "case-linked", 0, operationSource()) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-linked", "case-linked") ++ require.NoError(t, err) ++ if kind == "user" { ++ _, err = db.GetEngine(ctx).Exec("UPDATE `user` SET login_source = ? WHERE id = 1", r.SourceID) ++ } else { ++ _, err = db.GetEngine(ctx).Exec("INSERT INTO external_login_user (external_id, user_id, login_source_id) VALUES (?, ?, ?)", "synthetic-external", 1, r.SourceID) ++ } ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ _, err = auth.InverseSourceOperation(ctx, "operation-linked", "case-linked") ++ require.True(t, auth.IsErrSourceInUse(err)) ++ require.Equal(t, before, operationRows(t, ctx)) ++ if kind == "user" { ++ rows, e := db.GetEngine(ctx).Query("SELECT login_source FROM `user` WHERE id = 1") ++ require.NoError(t, e) ++ require.NotEqual(t, []byte("0"), rows[0]["login_source"]) ++ } else { ++ n, e := db.GetEngine(ctx).Table("external_login_user").Count() ++ require.NoError(t, e) ++ require.EqualValues(t, 1, n) ++ } ++ }) ++ } ++} ++func TestSourceOperationPreparedCancellationAndIDReuse(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) ++ require.NoError(t, err) ++ _, err = auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ r, err := auth.InverseSourceOperation(ctx, "operation-cancelled", "case-cancelled") ++ require.NoError(t, err) ++ require.Equal(t, "cancelled", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++} ++func TestSourceOperationRejectsUnsafeRuntime(t *testing.T) { ++ ctx := operationDB(t) ++ setting.Database.LogSQL = true ++ t.Cleanup(func() { setting.Database.LogSQL = false }) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-no-logging", "case-no-logging", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) ++ setting.Database.LogSQL = false ++ err = db.WithTx(ctx, func(tx context.Context) error { ++ _, err := auth.PrepareSourceOperation(tx, "operation-nested", "case-nested", 0, operationSource()) ++ return err ++ }) ++ require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) ++} ++func TestSourceOperationRefusesSharedManagedSource(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-first-case", "case-first-case", 0, operationSource()) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-first-case", "case-first-case") ++ require.NoError(t, err) ++ shared := operationSource() ++ shared.ID = r.SourceID ++ _, err = auth.PrepareSourceOperation(ctx, "operation-second-case", "case-second-case", r.SourceID, shared) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++} ++func TestSourceOperationBoundsPrivateJournalCustody(t *testing.T) { ++ ctx := operationDB(t) ++ // A full unresolved-journal budget refuses new preparation, without deleting ++ // any intent or replacing private recovery data with an expiry assumption. ++ for i := 0; i < 64; i++ { ++ _, err := db.GetEngine(ctx).Exec("INSERT INTO login_source_operation (operation_id, case_id, target_name, phase) VALUES (?, ?, ?, ?)", fmt.Sprintf("bounded-operation-%03d", i), "bounded-case", fmt.Sprintf("bounded-target-%03d", i), "prepared") ++ require.NoError(t, err) ++ } ++ _, err := auth.PrepareSourceOperation(ctx, "operation-budget-full", "case-budget-full", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ n, err := db.GetEngine(ctx).Table("login_source_operation").Count() ++ require.NoError(t, err) ++ require.EqualValues(t, 64, n) ++} ++func TestSourceOperationRequiresExactPostState(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-post-exact", "case-post-exact", 0, operationSource()) ++ require.NoError(t, err) ++ _, err = db.GetEngine(ctx).Exec("CREATE TRIGGER source_operation_test_drift AFTER INSERT ON login_source BEGIN UPDATE login_source SET name = 'unexpected-rewrite' WHERE id = NEW.id; END") ++ require.NoError(t, err) ++ defer db.GetEngine(ctx).Exec("DROP TRIGGER source_operation_test_drift") ++ _, err = auth.ApplySourceOperation(ctx, "operation-post-exact", "case-post-exact") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-post-exact", "case-post-exact") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++} +--- /dev/null ++++ b/models/auth/source_operation_internal_test.go +@@ -0,0 +1,87 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth ++ ++import ( ++ "context" ++ "testing" ++ "time" ++ ++ "forgejo.org/models/db" ++ "xorm.io/xorm" ++) ++ ++func TestSourceOperationDatabaseWriterExclusion(t *testing.T) { ++ // Use DEFERRED transaction mode in the managed engine as well, so this ++ // test detects removal of the primitive's explicit first write. ++ previousContext := db.DefaultContext ++ previousEngine, ok := db.GetEngine(previousContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual fixture engine") ++ } ++ managed, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") ++ if err != nil { ++ t.Fatal(err) ++ } ++ db.SetDefaultEngine(context.Background(), managed) ++ defer func() { db.SetDefaultEngine(previousContext, previousEngine); managed.Close() }() ++ // A second actual connection executes uncooperative raw SQL, so this checks ++ // database exclusion rather than an advisory-lock convention. ++ raw, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") ++ if err != nil { ++ t.Fatal(err) ++ } ++ defer raw.Close() ++ locked := make(chan struct{}) ++ release := make(chan struct{}) ++ done := make(chan error, 1) ++ go func() { ++ done <- sourceOperationTx(db.DefaultContext, func(ctx context.Context) error { close(locked); <-release; return nil }) ++ }() ++ select { ++ case <-locked: ++ case err := <-done: ++ t.Fatalf("lock failed: %v", err) ++ case <-time.After(5 * time.Second): ++ t.Fatal("lock timed out") ++ } ++ writeDone := make(chan error, 1) ++ go func() { _, err := raw.Exec("UPDATE login_source SET name = name WHERE id = -123"); writeDone <- err }() ++ select { ++ case err := <-writeDone: ++ close(release) ++ <-done ++ t.Fatalf("uncooperative write escaped exclusion: %v", err) ++ case <-time.After(100 * time.Millisecond): ++ } ++ close(release) ++ select { ++ case err := <-done: ++ if err != nil { ++ t.Fatal(err) ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("transaction did not finish") ++ } ++ select { ++ case err := <-writeDone: ++ if err != nil { ++ t.Fatal(err) ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("writer did not resume") ++ } ++} ++func TestSourceOperationRefusesActualXORMArgumentLogging(t *testing.T) { ++ engine, ok := db.GetEngine(db.DefaultContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual engine") ++ } ++ engine.ShowSQL(true) ++ defer engine.ShowSQL(false) ++ called := false ++ err := sourceOperationTx(db.DefaultContext, func(context.Context) error { called = true; return nil }) ++ if err != ErrSourceOperationUnsupported || called { ++ t.Fatal("actual SQL argument logger did not fail closed") ++ } ++} +--- /dev/null ++++ b/models/auth/source_operation_process_test.go +@@ -0,0 +1,97 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth_test ++ ++import ( ++ "context" ++ "os" ++ "os/exec" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ auth "forgejo.org/models/auth" ++ "forgejo.org/models/db" ++ oauth2 "forgejo.org/services/auth/source/oauth2" ++ ++ "github.com/stretchr/testify/require" ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++ "xorm.io/xorm/names" ++) ++ ++type operationCrashAfterSourceInsert struct{} ++ ++func (operationCrashAfterSourceInsert) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { ++ return c.Ctx, nil ++} ++func (operationCrashAfterSourceInsert) AfterProcess(c *contexts.ContextHook) error { ++ if c.Err == nil && strings.Contains(c.SQL, "INSERT INTO") && strings.Contains(c.SQL, "login_source") && !strings.Contains(c.SQL, "login_source_operation") { ++ // Process death after the real INSERT, before actual ApplySourceOperation's ++ // post-read/journal update/commit. No artificial operation is substituted. ++ os.Exit(73) ++ } ++ return nil ++} ++func TestSourceOperationProcessRecovery(t *testing.T) { ++ if step := os.Getenv("SOURCE_OPERATION_CHILD_STEP"); step != "" { ++ e, err := xorm.NewEngine("sqlite3", os.Getenv("SOURCE_OPERATION_CHILD_DB")+"?_txlock=deferred") ++ require.NoError(t, err) ++ e.SetMapper(names.GonicMapper{}) ++ db.SetDefaultEngine(context.Background(), e) ++ require.NoError(t, db.SyncAllTables()) ++ auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) ++ ctx := db.DefaultContext ++ switch step { ++ case "prepare": ++ _, err = auth.PrepareSourceOperation(ctx, "operation-process-recovery", "case-process-recovery", 0, operationSource()) ++ require.NoError(t, err) ++ case "crash": ++ e.AddHook(operationCrashAfterSourceInsert{}) ++ _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ t.Fatal("real insert did not reach crash hook") ++ case "recover-prepared": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ n, err := db.GetEngine(ctx).Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ case "recover-applied": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ _, err = auth.InverseSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ case "recover-inverse": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ n, err := db.GetEngine(ctx).Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ default: ++ t.Fatal("unknown synthetic child step") ++ } ++ require.NoError(t, e.Close()) ++ return ++ } ++ exe, err := os.Executable() ++ require.NoError(t, err) ++ path := filepath.Join(t.TempDir(), "owner-private.sqlite") ++ for _, step := range []string{"prepare", "crash", "recover-prepared", "recover-applied", "recover-inverse"} { ++ child := exec.Command(exe, "-test.run=^TestSourceOperationProcessRecovery$", "-test.v") ++ child.Env = append(os.Environ(), "SOURCE_OPERATION_CHILD_STEP="+step, "SOURCE_OPERATION_CHILD_DB="+path) ++ output, err := child.CombinedOutput() ++ if step == "crash" { ++ exit, ok := err.(*exec.ExitError) ++ require.True(t, ok, string(output)) ++ require.Equal(t, 73, exit.ExitCode(), string(output)) ++ } else { ++ require.NoError(t, err, string(output)) ++ } ++ } ++} +--- a/models/db/engine.go ++++ b/models/db/engine.go +@@ -330,8 +330,12 @@ + func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + ctx, task := trace.NewTask(c.Ctx, "sql") + ctx = context.WithValue(ctx, sqlTask{}, task) +- trace.Log(ctx, "query", c.SQL) +- trace.Logf(ctx, "args", "%v", c.Args) ++ if isSensitiveSQL(c.Ctx) { ++ trace.Log(ctx, "query", "[owner-confined query]") ++ } else { ++ trace.Log(ctx, "query", c.SQL) ++ trace.Logf(ctx, "args", "%v", c.Args) ++ } + return ctx, nil + } + +@@ -353,7 +357,11 @@ + + func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.ExecuteTime >= h.Treshold { +- h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] [owner-confined query] - %v", c.ExecuteTime) ++ } else { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ } + } + return nil + } +@@ -370,7 +378,11 @@ + + func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.Err != nil && !errors.Is(c.Err, context.Canceled) { +- h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) +- } +- return nil +-} ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] [owner-confined query]") ++ } else { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) ++ } ++ } ++ return nil ++} +--- a/models/db/context.go ++++ b/models/db/context.go +@@ -72,6 +72,12 @@ + // GetEngine will get a db Engine from this context or return an Engine restricted to this context + func GetEngine(ctx context.Context) Engine { + if e := getEngine(ctx); e != nil { ++ // A private-query marker must reach XORM's real query hooks, including ++ // inside an existing engine/transaction context. Wrapping the Go ++ // context alone does not change XORM's stored session context. ++ if isSensitiveSQL(ctx) { ++ return e.Context(ctx) ++ } + return e + } + return x.Context(ctx) +--- /dev/null ++++ b/models/db/sensitive_sql.go +@@ -0,0 +1,18 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import "context" ++ ++type sensitiveSQLKey struct{} ++ ++// WithSensitiveSQL suppresses argument, statement and driver-error disclosure ++// in the application's query hooks. The caller must separately refuse raw XORM ++// SQL logging; custom driver/server auditing remains an owner custody concern. ++func WithSensitiveSQL(ctx context.Context) context.Context { ++ return context.WithValue(ctx, sensitiveSQLKey{}, true) ++} ++func isSensitiveSQL(ctx context.Context) bool { ++ sensitive, _ := ctx.Value(sensitiveSQLKey{}).(bool) ++ return sensitive ++} +--- /dev/null ++++ b/models/db/sensitive_sql_test.go +@@ -0,0 +1,103 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import ( ++ "bytes" ++ "context" ++ "errors" ++ "fmt" ++ "runtime/trace" ++ "strings" ++ "testing" ++ "time" ++ ++ "forgejo.org/modules/log" ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++) ++ ++type sensitiveSQLLog struct { ++ log.Logger ++ entries []string ++} ++ ++func (l *sensitiveSQLLog) Log(_ int, _ log.Level, f string, v ...any) { ++ l.entries = append(l.entries, fmt.Sprintf(f, v...)) ++} ++func TestSensitiveSQLHooks(t *testing.T) { ++ const secret = "synthetic-query-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ ctx := context.Background() ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ h := &contexts.ContextHook{Ctx: ctx, SQL: "SELECT '" + secret + "'", Args: []any{secret}, Err: errors.New(secret), ExecuteTime: time.Second} ++ logger := new(sensitiveSQLLog) ++ if err := (&SlowQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ if err := (&ErrorQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ found := strings.Contains(strings.Join(logger.entries, "\n"), secret) ++ if found == sensitive { ++ t.Fatal("query hook disclosure/redaction mismatch") ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ next, err := (TracingHook{}).BeforeProcess(h) ++ if err != nil { ++ trace.Stop() ++ t.Fatal(err) ++ } ++ h.Ctx = next ++ err = (TracingHook{}).AfterProcess(h) ++ trace.Stop() ++ if err != nil { ++ t.Fatal(err) ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("trace disclosure/redaction mismatch") ++ } ++ } ++} ++ ++func TestSensitiveSQLActualContextPropagation(t *testing.T) { ++ engine, ok := GetEngine(DefaultContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual fixture engine") ++ } ++ logger := new(sensitiveSQLLog) ++ engine.AddHook(&SlowQueryHook{Logger: logger}) ++ engine.AddHook(&ErrorQueryHook{Logger: logger}) ++ engine.AddHook(TracingHook{}) ++ const secret = "synthetic-actual-xorm-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ logger.entries = nil ++ ctx := context.Context(DefaultContext) ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ _, err := GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", secret) ++ trace.Stop() ++ if err == nil { ++ t.Fatal("expected actual SQLite function refusal") ++ } ++ if len(logger.entries) < 2 { ++ t.Fatal("actual query hooks did not execute") ++ } ++ if strings.Contains(strings.Join(logger.entries, "\n"), secret) == sensitive { ++ t.Fatal("actual XORM query context did not preserve redaction") ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("actual XORM trace context did not preserve redaction") ++ } ++ } ++} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-private-query-common.patch b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-private-query-common.patch new file mode 100644 index 00000000..cd84b037 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-private-query-common.patch @@ -0,0 +1,188 @@ +--- a/models/db/engine.go ++++ b/models/db/engine.go +@@ -330,8 +330,12 @@ + func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + ctx, task := trace.NewTask(c.Ctx, "sql") + ctx = context.WithValue(ctx, sqlTask{}, task) +- trace.Log(ctx, "query", c.SQL) +- trace.Logf(ctx, "args", "%v", c.Args) ++ if isSensitiveSQL(c.Ctx) { ++ trace.Log(ctx, "query", "[owner-confined query]") ++ } else { ++ trace.Log(ctx, "query", c.SQL) ++ trace.Logf(ctx, "args", "%v", c.Args) ++ } + return ctx, nil + } + +@@ -353,7 +357,11 @@ + + func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.ExecuteTime >= h.Treshold { +- h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] [owner-confined query] - %v", c.ExecuteTime) ++ } else { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ } + } + return nil + } +@@ -370,7 +378,11 @@ + + func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.Err != nil && !errors.Is(c.Err, context.Canceled) { +- h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) +- } +- return nil +-} ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] [owner-confined query]") ++ } else { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) ++ } ++ } ++ return nil ++} +--- a/models/db/context.go ++++ b/models/db/context.go +@@ -72,6 +72,12 @@ + // GetEngine will get a db Engine from this context or return an Engine restricted to this context + func GetEngine(ctx context.Context) Engine { + if e := getEngine(ctx); e != nil { ++ // A private-query marker must reach XORM's real query hooks, including ++ // inside an existing engine/transaction context. Wrapping the Go ++ // context alone does not change XORM's stored session context. ++ if isSensitiveSQL(ctx) { ++ return e.Context(ctx) ++ } + return e + } + return x.Context(ctx) +--- /dev/null ++++ b/models/db/sensitive_sql.go +@@ -0,0 +1,18 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import "context" ++ ++type sensitiveSQLKey struct{} ++ ++// WithSensitiveSQL suppresses argument, statement and driver-error disclosure ++// in the application's query hooks. The caller must separately refuse raw XORM ++// SQL logging; custom driver/server auditing remains an owner custody concern. ++func WithSensitiveSQL(ctx context.Context) context.Context { ++ return context.WithValue(ctx, sensitiveSQLKey{}, true) ++} ++func isSensitiveSQL(ctx context.Context) bool { ++ sensitive, _ := ctx.Value(sensitiveSQLKey{}).(bool) ++ return sensitive ++} +--- /dev/null ++++ b/models/db/sensitive_sql_test.go +@@ -0,0 +1,103 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import ( ++ "bytes" ++ "context" ++ "errors" ++ "fmt" ++ "runtime/trace" ++ "strings" ++ "testing" ++ "time" ++ ++ "forgejo.org/modules/log" ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++) ++ ++type sensitiveSQLLog struct { ++ log.Logger ++ entries []string ++} ++ ++func (l *sensitiveSQLLog) Log(_ int, _ log.Level, f string, v ...any) { ++ l.entries = append(l.entries, fmt.Sprintf(f, v...)) ++} ++func TestSensitiveSQLHooks(t *testing.T) { ++ const secret = "synthetic-query-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ ctx := context.Background() ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ h := &contexts.ContextHook{Ctx: ctx, SQL: "SELECT '" + secret + "'", Args: []any{secret}, Err: errors.New(secret), ExecuteTime: time.Second} ++ logger := new(sensitiveSQLLog) ++ if err := (&SlowQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ if err := (&ErrorQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ found := strings.Contains(strings.Join(logger.entries, "\n"), secret) ++ if found == sensitive { ++ t.Fatal("query hook disclosure/redaction mismatch") ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ next, err := (TracingHook{}).BeforeProcess(h) ++ if err != nil { ++ trace.Stop() ++ t.Fatal(err) ++ } ++ h.Ctx = next ++ err = (TracingHook{}).AfterProcess(h) ++ trace.Stop() ++ if err != nil { ++ t.Fatal(err) ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("trace disclosure/redaction mismatch") ++ } ++ } ++} ++ ++func TestSensitiveSQLActualContextPropagation(t *testing.T) { ++ engine, ok := GetEngine(DefaultContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual fixture engine") ++ } ++ logger := new(sensitiveSQLLog) ++ engine.AddHook(&SlowQueryHook{Logger: logger}) ++ engine.AddHook(&ErrorQueryHook{Logger: logger}) ++ engine.AddHook(TracingHook{}) ++ const secret = "synthetic-actual-xorm-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ logger.entries = nil ++ ctx := context.Context(DefaultContext) ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ _, err := GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", secret) ++ trace.Stop() ++ if err == nil { ++ t.Fatal("expected actual SQLite function refusal") ++ } ++ if len(logger.entries) < 2 { ++ t.Fatal("actual query hooks did not execute") ++ } ++ if strings.Contains(strings.Join(logger.entries, "\n"), secret) == sensitive { ++ t.Fatal("actual XORM query context did not preserve redaction") ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("actual XORM trace context did not preserve redaction") ++ } ++ } ++} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation.go new file mode 100644 index 00000000..5fae47d0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation.go @@ -0,0 +1,430 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +// This owner-confined database primitive is NOT an authorization boundary. +// No CLI/web/API route exposes it. The admitted runtime must separately obtain +// fresh authenticated apply/inverse sessions and retain signed public evidence. +// Configurations and private journal rows must never leave database custody. + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "regexp" + "sort" + + "forgejo.org/models/db" + "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" + "xorm.io/xorm" +) + +var ( + ErrSourceOperationConflict = errors.New("source operation requires owner reconciliation") + ErrSourceOperationStorage = errors.New("source operation storage failure; reconcile before retry") + ErrSourceOperationUnsupported = errors.New("source operation requires standalone SQLite/PostgreSQL transaction with SQL logging disabled") + operationIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{7,127}$`) +) + +// Private source rows deliberately bypass Config decoding: decoding and +// reserializing can erase unknown fields, including a gate on an older image. +type sourceOperationRow struct { + ID int64 `xorm:"pk autoincr"` + Type Type + Name string + IsActive bool + IsSyncEnabled bool + Cfg string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp + UpdatedUnix timeutil.TimeStamp +} + +func (sourceOperationRow) TableName() string { return "login_source" } + +// The whole inventory, including private configs, stays in the owner's database. +// Neither journal payload nor its digest is a public result. +type sourceOperationJournal struct { + OperationID string `xorm:"pk VARCHAR(128)"` + CaseID string `xorm:"VARCHAR(128)"` + TargetName string + TargetID int64 + Phase string + BeforeState string `xorm:"TEXT"` + DesiredState string `xorm:"TEXT"` + PostState string `xorm:"TEXT"` +} + +func (sourceOperationJournal) TableName() string { return "login_source_operation" } +func init() { db.RegisterModel(new(sourceOperationJournal)) } + +// SourceOperationResult contains only public outcome metadata. An authenticated +// session/evidence producer must bind this result to its actual principal, +// admitted source revision, exact parameters and current operation session. +type SourceOperationResult struct { + OperationID string + Phase string + SourceID int64 +} + +// sourceOperationTx acquires a database-enforced lock before any source read. +// SQLite's first write serializes every database writer, including stock admin +// CRUD. PostgreSQL table locks exclude ordinary SQL source and user-link writes. +// Privileged DDL, process/provider refresh and release writers are outside this +// bounded primitive; they still require owner runtime/release exclusion. +func sourceOperationTx(ctx context.Context, f func(context.Context) error) error { + if db.InTransaction(ctx) || setting.Database.LogSQL || (!setting.Database.Type.IsSQLite3() && !setting.Database.Type.IsPostgreSQL()) { + return ErrSourceOperationUnsupported + } + err := db.WithTx(db.WithSensitiveSQL(ctx), func(tx context.Context) error { + session, ok := db.GetEngine(tx).(*xorm.Session) + if !ok || session.Engine().Logger().IsShowSQL() { + return ErrSourceOperationUnsupported + } + if setting.Database.Type.IsPostgreSQL() { + if _, err := db.GetEngine(tx).Exec(`LOCK TABLE login_source_operation, login_source, "user", external_login_user IN EXCLUSIVE MODE`); err != nil { + return err + } + } else { + // Even with no matching row, UPDATE starts SQLite's write transaction. + if _, err := db.GetEngine(tx).Exec(`UPDATE login_source_operation SET phase = phase WHERE operation_id = ''`); err != nil { + return err + } + } + return f(tx) + }) + if err == nil || errors.Is(err, ErrSourceOperationConflict) || errors.Is(err, ErrSourceOperationUnsupported) || IsErrSourceInUse(err) { + return err + } + return ErrSourceOperationStorage +} + +func sourceInventory(ctx context.Context) ([]sourceOperationRow, error) { + rows := make([]sourceOperationRow, 0) + err := db.GetEngine(ctx).OrderBy("id").Find(&rows) + return rows, err +} +func encodeSourceInventory(rows []sourceOperationRow) string { + b, _ := json.Marshal(rows) + return string(b) +} +func decodeSourceInventory(s string) ([]sourceOperationRow, error) { + var rows []sourceOperationRow + if err := json.Unmarshal([]byte(s), &rows); err != nil { + return nil, ErrSourceOperationConflict + } + return rows, nil +} +func sourceJournal(ctx context.Context, operationID string) (*sourceOperationJournal, error) { + row := new(sourceOperationJournal) + found, err := db.GetEngine(ctx).ID(operationID).Get(row) + if err != nil { + return nil, err + } + if !found { + return nil, ErrSourceOperationConflict + } + return row, nil +} +func journalResult(j *sourceOperationJournal) SourceOperationResult { + return SourceOperationResult{OperationID: j.OperationID, Phase: j.Phase, SourceID: j.TargetID} +} + +// PrepareSourceOperation durably captures exact source identity and complete +// original inventory before mutation. desired is private owner input, not an +// authorization token. targetID=0 requires authoritative name absence. Existing +// sources require both exact ID and name. Reuse of an operation ID is refused. +func PrepareSourceOperation(ctx context.Context, operationID, caseID string, targetID int64, desired *Source) (SourceOperationResult, error) { + var result SourceOperationResult + if !operationIDPattern.MatchString(operationID) || !operationIDPattern.MatchString(caseID) || desired == nil || desired.Type != OAuth2 || desired.Name == "" || desired.Cfg == nil || desired.IsSyncEnabled || desired.ID != targetID { + return result, ErrSourceOperationConflict + } + cfg, err := desired.Cfg.ToDB() + if err != nil || len(cfg) > 64*1024 || !json.Valid(cfg) { + return result, ErrSourceOperationConflict + } + candidate := sourceOperationRow{ID: targetID, Type: OAuth2, Name: desired.Name, IsActive: desired.IsActive, Cfg: string(cfg)} + candidateBytes, _ := json.Marshal(candidate) + err = sourceOperationTx(ctx, func(tx context.Context) error { + // Bound confidential duplicate custody. Refuse rather than erase journals + // with pending recovery or silently expire terminal private snapshots. + count, err := db.GetEngine(tx).Count(new(sourceOperationJournal)) + if err != nil { + return err + } + if count >= 256 { + return ErrSourceOperationConflict + } + active, err := db.GetEngine(tx).Where("phase IN (?, ?, ?)", "prepared", "applied", "unchanged").Count(new(sourceOperationJournal)) + if err != nil { + return err + } + if active >= 64 { + return ErrSourceOperationConflict + } + found, err := db.GetEngine(tx).ID(operationID).Exist(new(sourceOperationJournal)) + if err != nil { + return err + } + if found { + return ErrSourceOperationConflict + } + shared, err := db.GetEngine(tx).Where("target_name = ? AND phase IN (?, ?, ?)", desired.Name, "prepared", "applied", "unchanged").Exist(new(sourceOperationJournal)) + if err != nil { + return err + } + if shared { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if len(encodeSourceInventory(rows)) > 1024*1024 { + return ErrSourceOperationConflict + } + matched := false + for _, row := range rows { + if row.Name == desired.Name || row.ID == targetID { + if targetID == 0 || row.ID != targetID || row.Name != desired.Name || row.Type != OAuth2 { + return ErrSourceOperationConflict + } + matched = true + } + } + if targetID != 0 && !matched { + return ErrSourceOperationConflict + } + j := &sourceOperationJournal{OperationID: operationID, CaseID: caseID, TargetName: desired.Name, TargetID: targetID, Phase: "prepared", BeforeState: encodeSourceInventory(rows), DesiredState: string(candidateBytes)} + if _, err := db.GetEngine(tx).Insert(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// ApplySourceOperation atomically compares every original raw source row, +// mutates exactly the target and persists confirmed post-state. A lost commit +// acknowledgement is reconciled using the same operation ID, never a fresh ID. +// No source registration/network call occurs inside the database transaction. +func ApplySourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if j.Phase == "applied" || j.Phase == "unchanged" { + if encodeSourceInventory(rows) != j.PostState { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + } + if j.Phase != "prepared" || encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + var desired sourceOperationRow + if json.Unmarshal([]byte(j.DesiredState), &desired) != nil { + return ErrSourceOperationConflict + } + unchanged := false + for _, row := range rows { + if row.ID == j.TargetID { + desired.CreatedUnix = row.CreatedUnix + desired.UpdatedUnix = row.UpdatedUnix + unchanged = reflect.DeepEqual(row, desired) + } + } + if !unchanged { + desired.UpdatedUnix = timeutil.TimeStampNow() + if j.TargetID == 0 { + desired.CreatedUnix = desired.UpdatedUnix + if _, err := db.GetEngine(tx).NoAutoTime().Insert(&desired); err != nil { + return err + } + j.TargetID = desired.ID + } else { + n, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(&desired) + if err != nil { + return err + } + if n != 1 { + return ErrSourceOperationConflict + } + } + } + post, err := sourceInventory(tx) + if err != nil { + return err + } + expected := append([]sourceOperationRow{}, rows...) + if !unchanged { + replaced := false + for i := range expected { + if expected[i].ID == desired.ID { + expected[i] = desired + replaced = true + } + } + if !replaced { + expected = append(expected, desired) + } + sort.Slice(expected, func(i, k int) bool { return expected[i].ID < expected[k].ID }) + } + if !reflect.DeepEqual(post, expected) { + return ErrSourceOperationConflict + } + j.PostState = encodeSourceInventory(post) + j.Phase = "applied" + if unchanged { + j.Phase = "unchanged" + } + if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// InverseSourceOperation requires fresh external inverse authority on EACH call. +// It restores a preexisting source in place; only confirmed case creation can +// delete. Any linked local/external account refuses deletion without alteration. +func InverseSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if j.Phase == "reverted" || j.Phase == "cancelled" { + if encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + } + if j.Phase == "prepared" { + if encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + j.Phase = "cancelled" + } else { + if (j.Phase != "applied" && j.Phase != "unchanged") || encodeSourceInventory(rows) != j.PostState { + return ErrSourceOperationConflict + } + before, err := decodeSourceInventory(j.BeforeState) + if err != nil { + return err + } + var original *sourceOperationRow + for i := range before { + if before[i].ID == j.TargetID { + original = &before[i] + } + } + if original != nil { + if _, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(original); err != nil { + return err + } + } else { + for _, table := range []struct{ name, column string }{{"user", "login_source"}, {"external_login_user", "login_source_id"}} { + count, err := db.GetEngine(tx).Table(table.name).Where(table.column+" = ?", j.TargetID).Count() + if err != nil { + return err + } + if count != 0 { + return ErrSourceInUse{ID: j.TargetID} + } + } + n, err := db.GetEngine(tx).ID(j.TargetID).Delete(new(sourceOperationRow)) + if err != nil { + return err + } + if n != 1 { + return ErrSourceOperationConflict + } + } + restored, err := sourceInventory(tx) + if err != nil { + return err + } + if encodeSourceInventory(restored) != j.BeforeState { + return ErrSourceOperationConflict + } + j.Phase = "reverted" + } + if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// ReconcileSourceOperation only checks committed journal/current equality. It +// never infers that a prepared intent ran and never retries the mutation. The +// separately authenticated runtime must call this after an ambiguous result. +func ReconcileSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + expected := j.BeforeState + switch j.Phase { + case "prepared", "reverted", "cancelled": + case "applied", "unchanged": + expected = j.PostState + default: + return ErrSourceOperationConflict + } + if encodeSourceInventory(rows) != expected { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_internal_test.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_internal_test.go new file mode 100644 index 00000000..3dd07eaf --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_internal_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth + +import ( + "context" + "testing" + "time" + + "forgejo.org/models/db" + "xorm.io/xorm" +) + +func TestSourceOperationDatabaseWriterExclusion(t *testing.T) { + // Use DEFERRED transaction mode in the managed engine as well, so this + // test detects removal of the primitive's explicit first write. + previousContext := db.DefaultContext + previousEngine, ok := db.GetEngine(previousContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual fixture engine") + } + managed, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") + if err != nil { + t.Fatal(err) + } + db.SetDefaultEngine(context.Background(), managed) + defer func() { db.SetDefaultEngine(previousContext, previousEngine); managed.Close() }() + // A second actual connection executes uncooperative raw SQL, so this checks + // database exclusion rather than an advisory-lock convention. + raw, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") + if err != nil { + t.Fatal(err) + } + defer raw.Close() + locked := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- sourceOperationTx(db.DefaultContext, func(ctx context.Context) error { close(locked); <-release; return nil }) + }() + select { + case <-locked: + case err := <-done: + t.Fatalf("lock failed: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("lock timed out") + } + writeDone := make(chan error, 1) + go func() { _, err := raw.Exec("UPDATE login_source SET name = name WHERE id = -123"); writeDone <- err }() + select { + case err := <-writeDone: + close(release) + <-done + t.Fatalf("uncooperative write escaped exclusion: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("transaction did not finish") + } + select { + case err := <-writeDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("writer did not resume") + } +} +func TestSourceOperationRefusesActualXORMArgumentLogging(t *testing.T) { + engine, ok := db.GetEngine(db.DefaultContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual engine") + } + engine.ShowSQL(true) + defer engine.ShowSQL(false) + called := false + err := sourceOperationTx(db.DefaultContext, func(context.Context) error { called = true; return nil }) + if err != ErrSourceOperationUnsupported || called { + t.Fatal("actual SQL argument logger did not fail closed") + } +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_process_test.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_process_test.go new file mode 100644 index 00000000..b5defd94 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_process_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + auth "forgejo.org/models/auth" + "forgejo.org/models/db" + oauth2 "forgejo.org/services/auth/source/oauth2" + + "github.com/stretchr/testify/require" + "xorm.io/xorm" + "xorm.io/xorm/contexts" + "xorm.io/xorm/names" +) + +type operationCrashAfterSourceInsert struct{} + +func (operationCrashAfterSourceInsert) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} +func (operationCrashAfterSourceInsert) AfterProcess(c *contexts.ContextHook) error { + if c.Err == nil && strings.Contains(c.SQL, "INSERT INTO") && strings.Contains(c.SQL, "login_source") && !strings.Contains(c.SQL, "login_source_operation") { + // Process death after the real INSERT, before actual ApplySourceOperation's + // post-read/journal update/commit. No artificial operation is substituted. + os.Exit(73) + } + return nil +} +func TestSourceOperationProcessRecovery(t *testing.T) { + if step := os.Getenv("SOURCE_OPERATION_CHILD_STEP"); step != "" { + e, err := xorm.NewEngine("sqlite3", os.Getenv("SOURCE_OPERATION_CHILD_DB")+"?_txlock=deferred") + require.NoError(t, err) + e.SetMapper(names.GonicMapper{}) + db.SetDefaultEngine(context.Background(), e) + require.NoError(t, db.SyncAllTables()) + auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) + ctx := db.DefaultContext + switch step { + case "prepare": + _, err = auth.PrepareSourceOperation(ctx, "operation-process-recovery", "case-process-recovery", 0, operationSource()) + require.NoError(t, err) + case "crash": + e.AddHook(operationCrashAfterSourceInsert{}) + _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + t.Fatal("real insert did not reach crash hook") + case "recover-prepared": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) + n, err := db.GetEngine(ctx).Table("login_source").Count() + require.NoError(t, err) + require.Zero(t, n) + _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + case "recover-applied": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "applied", r.Phase) + _, err = auth.InverseSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + case "recover-inverse": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "reverted", r.Phase) + n, err := db.GetEngine(ctx).Table("login_source").Count() + require.NoError(t, err) + require.Zero(t, n) + default: + t.Fatal("unknown synthetic child step") + } + require.NoError(t, e.Close()) + return + } + exe, err := os.Executable() + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "owner-private.sqlite") + for _, step := range []string{"prepare", "crash", "recover-prepared", "recover-applied", "recover-inverse"} { + child := exec.Command(exe, "-test.run=^TestSourceOperationProcessRecovery$", "-test.v") + child.Env = append(os.Environ(), "SOURCE_OPERATION_CHILD_STEP="+step, "SOURCE_OPERATION_CHILD_DB="+path) + output, err := child.CombinedOutput() + if step == "crash" { + exit, ok := err.(*exec.ExitError) + require.True(t, ok, string(output)) + require.Equal(t, 73, exit.ExitCode(), string(output)) + } else { + require.NoError(t, err, string(output)) + } + } +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_test.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_test.go new file mode 100644 index 00000000..f4f6dfbc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/auth/source_operation_test.go @@ -0,0 +1,235 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + auth "forgejo.org/models/auth" + "forgejo.org/models/db" + "forgejo.org/models/unittest" + "forgejo.org/modules/setting" + oauth2 "forgejo.org/services/auth/source/oauth2" + + "github.com/stretchr/testify/require" +) + +func operationDB(t *testing.T) context.Context { + t.Helper() + require.NoError(t, unittest.PrepareTestDatabase()) + _, err := db.GetEngine(db.DefaultContext).Exec("DELETE FROM login_source_operation") + require.NoError(t, err) + auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) + return db.DefaultContext +} +func operationSource() *auth.Source { + return &auth.Source{Type: auth.OAuth2, Name: "operation-test", IsActive: false, Cfg: &oauth2.Source{Provider: "openidConnect", ClientID: "synthetic-client", ClientSecret: "synthetic-private-original", RequiredClaimName: "groups", RequiredClaimValues: []string{"g1", "g2", "g3"}, Scopes: []string{"openid", "profile", "groups"}}} +} +func operationRows(t *testing.T, ctx context.Context) string { + t.Helper() + rows, err := db.GetEngine(ctx).Query("SELECT * FROM login_source ORDER BY id") + require.NoError(t, err) + b, err := json.Marshal(rows) + require.NoError(t, err) + return string(b) +} +func TestSourceOperationCreateAndInverse(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + r, err := auth.PrepareSourceOperation(ctx, "operation-create", "case-create", 0, operationSource()) + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) + r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, "applied", r.Phase) + require.Positive(t, r.SourceID) + id := r.SourceID + post := operationRows(t, ctx) + r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, id, r.SourceID) + require.Equal(t, post, operationRows(t, ctx)) + _, err = auth.InverseSourceOperation(ctx, "operation-create", "wrong-case") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + r, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, "reverted", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) + _, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + _, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) +} +func TestSourceOperationRestoresExactRawPreexisting(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + // The old/unknown field must survive restoration byte for byte, including the + // private synthetic secret. A typed OAuth2 decoder would silently lose it. + raw := `{"Provider":"openidConnect","ClientID":"synthetic-client","ClientSecret":"synthetic-private-original","FutureGuard":{"x":true}}` + _, err := db.GetEngine(ctx).Exec("UPDATE login_source SET cfg = ? WHERE id = ?", raw, s.ID) + require.NoError(t, err) + before := operationRows(t, ctx) + s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-new" + _, err = auth.PrepareSourceOperation(ctx, "operation-restore", "case-restore", s.ID, s) + require.NoError(t, err) + _, err = auth.ApplySourceOperation(ctx, "operation-restore", "case-restore") + require.NoError(t, err) + _, err = auth.InverseSourceOperation(ctx, "operation-restore", "case-restore") + require.NoError(t, err) + require.Equal(t, before, operationRows(t, ctx)) + source, err := auth.GetSourceByID(ctx, s.ID) + require.NoError(t, err) + require.Equal(t, s.ID, source.ID) +} +func TestSourceOperationUnchangedNeverDeletesPreexisting(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-unchanged", "case-unchanged", s.ID, s) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-unchanged", "case-unchanged") + require.NoError(t, err) + require.Equal(t, "unchanged", r.Phase) + _, err = auth.InverseSourceOperation(ctx, "operation-unchanged", "case-unchanged") + require.NoError(t, err) + require.Equal(t, before, operationRows(t, ctx)) +} +func TestSourceOperationPrivateDriftRefuses(t *testing.T) { + for _, stage := range []string{"before-apply", "before-inverse"} { + t.Run(stage, func(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + _, err := auth.PrepareSourceOperation(ctx, "operation-private-drift", "case-private-drift", s.ID, s) + require.NoError(t, err) + if stage == "before-inverse" { + _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") + require.NoError(t, err) + } + s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-drift" + require.NoError(t, auth.UpdateSource(ctx, s)) + drift := operationRows(t, ctx) + if stage == "before-apply" { + _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") + } else { + _, err = auth.InverseSourceOperation(ctx, "operation-private-drift", "case-private-drift") + } + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.NotContains(t, err.Error(), "synthetic-private") + require.Equal(t, drift, operationRows(t, ctx)) + }) + } +} +func TestSourceOperationStockCRUDDriftRefuses(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-other-writer", "case-other-writer", 0, operationSource()) + require.NoError(t, err) + other := operationSource() + other.Name = "unrelated-source" + require.NoError(t, auth.CreateSource(ctx, other)) + drift := operationRows(t, ctx) + _, err = auth.ApplySourceOperation(ctx, "operation-other-writer", "case-other-writer") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.Equal(t, drift, operationRows(t, ctx)) +} +func TestSourceOperationLinkedSourceRefusesDeletion(t *testing.T) { + for _, kind := range []string{"user", "external"} { + t.Run(kind, func(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-linked", "case-linked", 0, operationSource()) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-linked", "case-linked") + require.NoError(t, err) + if kind == "user" { + _, err = db.GetEngine(ctx).Exec("UPDATE `user` SET login_source = ? WHERE id = 1", r.SourceID) + } else { + _, err = db.GetEngine(ctx).Exec("INSERT INTO external_login_user (external_id, user_id, login_source_id) VALUES (?, ?, ?)", "synthetic-external", 1, r.SourceID) + } + require.NoError(t, err) + before := operationRows(t, ctx) + _, err = auth.InverseSourceOperation(ctx, "operation-linked", "case-linked") + require.True(t, auth.IsErrSourceInUse(err)) + require.Equal(t, before, operationRows(t, ctx)) + if kind == "user" { + rows, e := db.GetEngine(ctx).Query("SELECT login_source FROM `user` WHERE id = 1") + require.NoError(t, e) + require.NotEqual(t, []byte("0"), rows[0]["login_source"]) + } else { + n, e := db.GetEngine(ctx).Table("external_login_user").Count() + require.NoError(t, e) + require.EqualValues(t, 1, n) + } + }) + } +} +func TestSourceOperationPreparedCancellationAndIDReuse(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) + require.NoError(t, err) + _, err = auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + r, err := auth.InverseSourceOperation(ctx, "operation-cancelled", "case-cancelled") + require.NoError(t, err) + require.Equal(t, "cancelled", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) +} +func TestSourceOperationRejectsUnsafeRuntime(t *testing.T) { + ctx := operationDB(t) + setting.Database.LogSQL = true + t.Cleanup(func() { setting.Database.LogSQL = false }) + _, err := auth.PrepareSourceOperation(ctx, "operation-no-logging", "case-no-logging", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) + setting.Database.LogSQL = false + err = db.WithTx(ctx, func(tx context.Context) error { + _, err := auth.PrepareSourceOperation(tx, "operation-nested", "case-nested", 0, operationSource()) + return err + }) + require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) +} +func TestSourceOperationRefusesSharedManagedSource(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-first-case", "case-first-case", 0, operationSource()) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-first-case", "case-first-case") + require.NoError(t, err) + shared := operationSource() + shared.ID = r.SourceID + _, err = auth.PrepareSourceOperation(ctx, "operation-second-case", "case-second-case", r.SourceID, shared) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) +} +func TestSourceOperationBoundsPrivateJournalCustody(t *testing.T) { + ctx := operationDB(t) + // A full unresolved-journal budget refuses new preparation, without deleting + // any intent or replacing private recovery data with an expiry assumption. + for i := 0; i < 64; i++ { + _, err := db.GetEngine(ctx).Exec("INSERT INTO login_source_operation (operation_id, case_id, target_name, phase) VALUES (?, ?, ?, ?)", fmt.Sprintf("bounded-operation-%03d", i), "bounded-case", fmt.Sprintf("bounded-target-%03d", i), "prepared") + require.NoError(t, err) + } + _, err := auth.PrepareSourceOperation(ctx, "operation-budget-full", "case-budget-full", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + n, err := db.GetEngine(ctx).Table("login_source_operation").Count() + require.NoError(t, err) + require.EqualValues(t, 64, n) +} +func TestSourceOperationRequiresExactPostState(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-post-exact", "case-post-exact", 0, operationSource()) + require.NoError(t, err) + _, err = db.GetEngine(ctx).Exec("CREATE TRIGGER source_operation_test_drift AFTER INSERT ON login_source BEGIN UPDATE login_source SET name = 'unexpected-rewrite' WHERE id = NEW.id; END") + require.NoError(t, err) + defer db.GetEngine(ctx).Exec("DROP TRIGGER source_operation_test_drift") + _, err = auth.ApplySourceOperation(ctx, "operation-post-exact", "case-post-exact") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.Equal(t, before, operationRows(t, ctx)) + r, err := auth.ReconcileSourceOperation(ctx, "operation-post-exact", "case-post-exact") + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/context.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/context.go new file mode 100644 index 00000000..e69de29b diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/engine.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/engine.go new file mode 100644 index 00000000..0bb94ef6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/engine.go @@ -0,0 +1,388 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "reflect" + "runtime/trace" + "strings" + "time" + + "forgejo.org/modules/log" + "forgejo.org/modules/setting" + + "xorm.io/xorm" + "xorm.io/xorm/contexts" + "xorm.io/xorm/names" + "xorm.io/xorm/schemas" + + _ "github.com/go-sql-driver/mysql" // Needed for the MySQL driver + _ "github.com/lib/pq" // Needed for the Postgresql driver +) + +var ( + x *xorm.Engine + tables []any + initFuncs []func() error +) + +// Engine represents a xorm engine or session. +type Engine interface { + Table(tableNameOrBean any) *xorm.Session + Count(...any) (int64, error) + Decr(column string, arg ...any) *xorm.Session + Delete(...any) (int64, error) + Truncate(...any) (int64, error) + Exec(...any) (sql.Result, error) + Find(any, ...any) error + Get(beans ...any) (bool, error) + ID(any) *xorm.Session + In(string, ...any) *xorm.Session + Incr(column string, arg ...any) *xorm.Session + Insert(...any) (int64, error) + Iterate(any, xorm.IterFunc) error + IsTableExist(any) (bool, error) + Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session + SQL(any, ...any) *xorm.Session + Where(any, ...any) *xorm.Session + Asc(colNames ...string) *xorm.Session + Desc(colNames ...string) *xorm.Session + Limit(limit int, start ...int) *xorm.Session + NoAutoTime() *xorm.Session + SumInt(bean any, columnName string) (res int64, err error) + Sync(...any) error + Select(string) *xorm.Session + SetExpr(string, any) *xorm.Session + NotIn(string, ...any) *xorm.Session + OrderBy(any, ...any) *xorm.Session + Exist(...any) (bool, error) + Distinct(...string) *xorm.Session + Query(...any) ([]map[string][]byte, error) + Cols(...string) *xorm.Session + Context(ctx context.Context) *xorm.Session + Ping() error +} + +// TableInfo returns table's information via an object +func TableInfo(v any) (*schemas.Table, error) { + return x.TableInfo(v) +} + +// DumpTables dump tables information +func DumpTables(tables []*schemas.Table, w io.Writer, tp ...schemas.DBType) error { + return x.DumpTables(tables, w, tp...) +} + +// RegisterModel registers model, if initfunc provided, it will be invoked after data model sync +func RegisterModel(bean any, initFunc ...func() error) { + tables = append(tables, bean) + if len(initFuncs) > 0 && initFunc[0] != nil { + initFuncs = append(initFuncs, initFunc[0]) + } +} + +func init() { + gonicNames := []string{"SSL", "UID"} + for _, name := range gonicNames { + names.LintGonicMapper[name] = true + } +} + +// newXORMEngine returns a new XORM engine from the configuration +func newXORMEngine() (*xorm.Engine, error) { + connStr, err := setting.DBConnStr() + if err != nil { + return nil, err + } + + var engine *xorm.Engine + + if setting.Database.Type.IsPostgreSQL() && len(setting.Database.Schema) > 0 { + // OK whilst we sort out our schema issues - create a schema aware postgres + registerPostgresSchemaDriver() + engine, err = xorm.NewEngine("postgresschema", connStr) + } else { + engine, err = xorm.NewEngine(setting.Database.Type.String(), connStr) + } + + if err != nil { + return nil, err + } + if setting.Database.Type.IsMySQL() { + engine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"}) + } + engine.SetSchema(setting.Database.Schema) + return engine, nil +} + +// SyncAllTables sync the schemas of all tables, is required by unit test code +func SyncAllTables() error { + _, err := x.StoreEngine("InnoDB").SyncWithOptions(xorm.SyncOptions{ + WarnIfDatabaseColumnMissed: true, + }, tables...) + return err +} + +// InitEngine initializes the xorm.Engine and sets it as db.DefaultContext +func InitEngine(ctx context.Context) error { + xormEngine, err := newXORMEngine() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + xormEngine.SetMapper(names.GonicMapper{}) + // WARNING: for serv command, MUST remove the output to os.stdout, + // so use log file to instead print to stdout. + xormEngine.SetLogger(NewXORMLogger(setting.Database.LogSQL)) + xormEngine.ShowSQL(setting.Database.LogSQL) + xormEngine.SetMaxOpenConns(setting.Database.MaxOpenConns) + xormEngine.SetMaxIdleConns(setting.Database.MaxIdleConns) + xormEngine.SetConnMaxLifetime(setting.Database.ConnMaxLifetime) + xormEngine.SetConnMaxIdleTime(setting.Database.ConnMaxIdleTime) + xormEngine.SetDefaultContext(ctx) + + if setting.Database.SlowQueryThreshold > 0 { + xormEngine.AddHook(&SlowQueryHook{ + Treshold: setting.Database.SlowQueryThreshold, + Logger: log.GetLogger("xorm"), + }) + } + + errorLogger := log.GetLogger("xorm") + if setting.IsInTesting { + errorLogger = log.GetLogger(log.DEFAULT) + } + + xormEngine.AddHook(&ErrorQueryHook{ + Logger: errorLogger, + }) + + xormEngine.AddHook(&TracingHook{}) + + SetDefaultEngine(ctx, xormEngine) + return nil +} + +// SetDefaultEngine sets the default engine for db +func SetDefaultEngine(ctx context.Context, eng *xorm.Engine) { + x = eng + DefaultContext = &Context{ + Context: ctx, + e: x, + } +} + +// UnsetDefaultEngine closes and unsets the default engine +// We hope the SetDefaultEngine and UnsetDefaultEngine can be paired, but it's impossible now, +// there are many calls to InitEngine -> SetDefaultEngine directly to overwrite the `x` and DefaultContext without close +// Global database engine related functions are all racy and there is no graceful close right now. +func UnsetDefaultEngine() { + if x != nil { + _ = x.Close() + x = nil + } + DefaultContext = nil +} + +// InitEngineWithMigration initializes a new xorm.Engine and sets it as the db.DefaultContext +// This function must never call .Sync() if the provided migration function fails. +// When called from the "doctor" command, the migration function is a version check +// that prevents the doctor from fixing anything in the database if the migration level +// is different from the expected value. +func InitEngineWithMigration(ctx context.Context, migrateFunc func(*xorm.Engine) error) (err error) { + if err = InitEngine(ctx); err != nil { + return err + } + + if err = x.Ping(); err != nil { + return err + } + + preprocessDatabaseCollation(x) + + // We have to run migrateFunc here in case the user is re-running installation on a previously created DB. + // If we do not then table schemas will be changed and there will be conflicts when the migrations run properly. + // + // Installation should only be being re-run if users want to recover an old database. + // However, we should think carefully about should we support re-install on an installed instance, + // as there may be other problems due to secret reinitialization. + if err = migrateFunc(x); err != nil { + return fmt.Errorf("migrate: %w", err) + } + + if err = SyncAllTables(); err != nil { + return fmt.Errorf("sync database struct error: %w", err) + } + + for _, initFunc := range initFuncs { + if err := initFunc(); err != nil { + return fmt.Errorf("initFunc failed: %w", err) + } + } + + return nil +} + +// NamesToBean return a list of beans or an error +func NamesToBean(names ...string) ([]any, error) { + beans := []any{} + if len(names) == 0 { + beans = append(beans, tables...) + return beans, nil + } + // Need to map provided names to beans... + beanMap := make(map[string]any) + for _, bean := range tables { + beanMap[strings.ToLower(reflect.Indirect(reflect.ValueOf(bean)).Type().Name())] = bean + beanMap[strings.ToLower(x.TableName(bean))] = bean + beanMap[strings.ToLower(x.TableName(bean, true))] = bean + } + + gotBean := make(map[any]bool) + for _, name := range names { + bean, ok := beanMap[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return nil, fmt.Errorf("no table found that matches: %s", name) + } + if !gotBean[bean] { + beans = append(beans, bean) + gotBean[bean] = true + } + } + return beans, nil +} + +// DumpDatabase dumps all data from database according the special database SQL syntax to file system. +func DumpDatabase(filePath, dbType string) error { + var tbs []*schemas.Table + for _, t := range tables { + t, err := x.TableInfo(t) + if err != nil { + return err + } + tbs = append(tbs, t) + } + + type Version struct { + ID int64 `xorm:"pk autoincr"` + Version int64 + } + t, err := x.TableInfo(&Version{}) + if err != nil { + return err + } + tbs = append(tbs, t) + + if len(dbType) > 0 { + return x.DumpTablesToFile(tbs, filePath, schemas.DBType(dbType)) + } + return x.DumpTablesToFile(tbs, filePath) +} + +// MaxBatchInsertSize returns the table's max batch insert size +func MaxBatchInsertSize(bean any) int { + t, err := x.TableInfo(bean) + if err != nil { + return 50 + } + return 999 / len(t.ColumnsSeq()) +} + +// IsTableNotEmpty returns true if table has at least one record +func IsTableNotEmpty(beanOrTableName any) (bool, error) { + return x.Table(beanOrTableName).Exist() +} + +// DeleteAllRecords will delete all the records of this table +func DeleteAllRecords(tableName string) error { + _, err := x.Exec(fmt.Sprintf("DELETE FROM %s", tableName)) + return err +} + +// GetMaxID will return max id of the table +func GetMaxID(beanOrTableName any) (maxID int64, err error) { + _, err = x.Select("MAX(id)").Table(beanOrTableName).Get(&maxID) + return maxID, err +} + +func SetLogSQL(ctx context.Context, on bool) { + e := GetEngine(ctx) + if x, ok := e.(*xorm.Engine); ok { + x.ShowSQL(on) + } else if sess, ok := e.(*xorm.Session); ok { + sess.Engine().ShowSQL(on) + } +} + +type TracingHook struct{} + +var _ contexts.Hook = &TracingHook{} + +type sqlTask struct{} + +func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + ctx, task := trace.NewTask(c.Ctx, "sql") + ctx = context.WithValue(ctx, sqlTask{}, task) + if isSensitiveSQL(c.Ctx) { + trace.Log(ctx, "query", "[owner-confined query]") + } else { + trace.Log(ctx, "query", c.SQL) + trace.Logf(ctx, "args", "%v", c.Args) + } + return ctx, nil +} + +func (TracingHook) AfterProcess(c *contexts.ContextHook) error { + c.Ctx.Value(sqlTask{}).(*trace.Task).End() + return nil +} + +type SlowQueryHook struct { + Treshold time.Duration + Logger log.Logger +} + +var _ contexts.Hook = &SlowQueryHook{} + +func (SlowQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} + +func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.ExecuteTime >= h.Treshold { + if isSensitiveSQL(c.Ctx) { + h.Logger.Log(8, log.WARN, "[Slow SQL Query] [owner-confined query] - %v", c.ExecuteTime) + } else { + h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) + } + } + return nil +} + +type ErrorQueryHook struct { + Logger log.Logger +} + +var _ contexts.Hook = &ErrorQueryHook{} + +func (ErrorQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} + +func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.Err != nil && !errors.Is(c.Err, context.Canceled) { + if isSensitiveSQL(c.Ctx) { + h.Logger.Log(8, log.ERROR, "[Error SQL Query] [owner-confined query]") + } else { + h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) + } + } + return nil +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql.go new file mode 100644 index 00000000..246bb367 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package db + +import "context" + +type sensitiveSQLKey struct{} + +// WithSensitiveSQL suppresses argument, statement and driver-error disclosure +// in the application's query hooks. The caller must separately refuse raw XORM +// SQL logging; custom driver/server auditing remains an owner custody concern. +func WithSensitiveSQL(ctx context.Context) context.Context { + return context.WithValue(ctx, sensitiveSQLKey{}, true) +} +func isSensitiveSQL(ctx context.Context) bool { + sensitive, _ := ctx.Value(sensitiveSQLKey{}).(bool) + return sensitive +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql_test.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql_test.go new file mode 100644 index 00000000..973d76ca --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/postimages/models/db/sensitive_sql_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package db + +import ( + "bytes" + "context" + "errors" + "fmt" + "runtime/trace" + "strings" + "testing" + "time" + + "forgejo.org/modules/log" + "xorm.io/xorm" + "xorm.io/xorm/contexts" +) + +type sensitiveSQLLog struct { + log.Logger + entries []string +} + +func (l *sensitiveSQLLog) Log(_ int, _ log.Level, f string, v ...any) { + l.entries = append(l.entries, fmt.Sprintf(f, v...)) +} +func TestSensitiveSQLHooks(t *testing.T) { + const secret = "synthetic-query-private-sentinel" + for _, sensitive := range []bool{false, true} { + ctx := context.Background() + if sensitive { + ctx = WithSensitiveSQL(ctx) + } + h := &contexts.ContextHook{Ctx: ctx, SQL: "SELECT '" + secret + "'", Args: []any{secret}, Err: errors.New(secret), ExecuteTime: time.Second} + logger := new(sensitiveSQLLog) + if err := (&SlowQueryHook{Logger: logger}).AfterProcess(h); err != nil { + t.Fatal(err) + } + if err := (&ErrorQueryHook{Logger: logger}).AfterProcess(h); err != nil { + t.Fatal(err) + } + found := strings.Contains(strings.Join(logger.entries, "\n"), secret) + if found == sensitive { + t.Fatal("query hook disclosure/redaction mismatch") + } + var out bytes.Buffer + if err := trace.Start(&out); err != nil { + t.Fatal(err) + } + next, err := (TracingHook{}).BeforeProcess(h) + if err != nil { + trace.Stop() + t.Fatal(err) + } + h.Ctx = next + err = (TracingHook{}).AfterProcess(h) + trace.Stop() + if err != nil { + t.Fatal(err) + } + if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { + t.Fatal("trace disclosure/redaction mismatch") + } + } +} + +func TestSensitiveSQLActualContextPropagation(t *testing.T) { + engine, ok := GetEngine(DefaultContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual fixture engine") + } + logger := new(sensitiveSQLLog) + engine.AddHook(&SlowQueryHook{Logger: logger}) + engine.AddHook(&ErrorQueryHook{Logger: logger}) + engine.AddHook(TracingHook{}) + const secret = "synthetic-actual-xorm-private-sentinel" + for _, sensitive := range []bool{false, true} { + logger.entries = nil + ctx := context.Context(DefaultContext) + if sensitive { + ctx = WithSensitiveSQL(ctx) + } + var out bytes.Buffer + if err := trace.Start(&out); err != nil { + t.Fatal(err) + } + _, err := GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", secret) + trace.Stop() + if err == nil { + t.Fatal("expected actual SQLite function refusal") + } + if len(logger.entries) < 2 { + t.Fatal("actual query hooks did not execute") + } + if strings.Contains(strings.Join(logger.entries, "\n"), secret) == sensitive { + t.Fatal("actual XORM query context did not preserve redaction") + } + if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { + t.Fatal("actual XORM trace context did not preserve redaction") + } + } +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/preimages/models/db/context.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/preimages/models/db/context.go new file mode 100644 index 00000000..e69de29b diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/preimages/models/db/engine.go b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/preimages/models/db/engine.go new file mode 100644 index 00000000..ca6576da --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/preimages/models/db/engine.go @@ -0,0 +1,376 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "reflect" + "runtime/trace" + "strings" + "time" + + "forgejo.org/modules/log" + "forgejo.org/modules/setting" + + "xorm.io/xorm" + "xorm.io/xorm/contexts" + "xorm.io/xorm/names" + "xorm.io/xorm/schemas" + + _ "github.com/go-sql-driver/mysql" // Needed for the MySQL driver + _ "github.com/lib/pq" // Needed for the Postgresql driver +) + +var ( + x *xorm.Engine + tables []any + initFuncs []func() error +) + +// Engine represents a xorm engine or session. +type Engine interface { + Table(tableNameOrBean any) *xorm.Session + Count(...any) (int64, error) + Decr(column string, arg ...any) *xorm.Session + Delete(...any) (int64, error) + Truncate(...any) (int64, error) + Exec(...any) (sql.Result, error) + Find(any, ...any) error + Get(beans ...any) (bool, error) + ID(any) *xorm.Session + In(string, ...any) *xorm.Session + Incr(column string, arg ...any) *xorm.Session + Insert(...any) (int64, error) + Iterate(any, xorm.IterFunc) error + IsTableExist(any) (bool, error) + Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session + SQL(any, ...any) *xorm.Session + Where(any, ...any) *xorm.Session + Asc(colNames ...string) *xorm.Session + Desc(colNames ...string) *xorm.Session + Limit(limit int, start ...int) *xorm.Session + NoAutoTime() *xorm.Session + SumInt(bean any, columnName string) (res int64, err error) + Sync(...any) error + Select(string) *xorm.Session + SetExpr(string, any) *xorm.Session + NotIn(string, ...any) *xorm.Session + OrderBy(any, ...any) *xorm.Session + Exist(...any) (bool, error) + Distinct(...string) *xorm.Session + Query(...any) ([]map[string][]byte, error) + Cols(...string) *xorm.Session + Context(ctx context.Context) *xorm.Session + Ping() error +} + +// TableInfo returns table's information via an object +func TableInfo(v any) (*schemas.Table, error) { + return x.TableInfo(v) +} + +// DumpTables dump tables information +func DumpTables(tables []*schemas.Table, w io.Writer, tp ...schemas.DBType) error { + return x.DumpTables(tables, w, tp...) +} + +// RegisterModel registers model, if initfunc provided, it will be invoked after data model sync +func RegisterModel(bean any, initFunc ...func() error) { + tables = append(tables, bean) + if len(initFuncs) > 0 && initFunc[0] != nil { + initFuncs = append(initFuncs, initFunc[0]) + } +} + +func init() { + gonicNames := []string{"SSL", "UID"} + for _, name := range gonicNames { + names.LintGonicMapper[name] = true + } +} + +// newXORMEngine returns a new XORM engine from the configuration +func newXORMEngine() (*xorm.Engine, error) { + connStr, err := setting.DBConnStr() + if err != nil { + return nil, err + } + + var engine *xorm.Engine + + if setting.Database.Type.IsPostgreSQL() && len(setting.Database.Schema) > 0 { + // OK whilst we sort out our schema issues - create a schema aware postgres + registerPostgresSchemaDriver() + engine, err = xorm.NewEngine("postgresschema", connStr) + } else { + engine, err = xorm.NewEngine(setting.Database.Type.String(), connStr) + } + + if err != nil { + return nil, err + } + if setting.Database.Type.IsMySQL() { + engine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"}) + } + engine.SetSchema(setting.Database.Schema) + return engine, nil +} + +// SyncAllTables sync the schemas of all tables, is required by unit test code +func SyncAllTables() error { + _, err := x.StoreEngine("InnoDB").SyncWithOptions(xorm.SyncOptions{ + WarnIfDatabaseColumnMissed: true, + }, tables...) + return err +} + +// InitEngine initializes the xorm.Engine and sets it as db.DefaultContext +func InitEngine(ctx context.Context) error { + xormEngine, err := newXORMEngine() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + xormEngine.SetMapper(names.GonicMapper{}) + // WARNING: for serv command, MUST remove the output to os.stdout, + // so use log file to instead print to stdout. + xormEngine.SetLogger(NewXORMLogger(setting.Database.LogSQL)) + xormEngine.ShowSQL(setting.Database.LogSQL) + xormEngine.SetMaxOpenConns(setting.Database.MaxOpenConns) + xormEngine.SetMaxIdleConns(setting.Database.MaxIdleConns) + xormEngine.SetConnMaxLifetime(setting.Database.ConnMaxLifetime) + xormEngine.SetConnMaxIdleTime(setting.Database.ConnMaxIdleTime) + xormEngine.SetDefaultContext(ctx) + + if setting.Database.SlowQueryThreshold > 0 { + xormEngine.AddHook(&SlowQueryHook{ + Treshold: setting.Database.SlowQueryThreshold, + Logger: log.GetLogger("xorm"), + }) + } + + errorLogger := log.GetLogger("xorm") + if setting.IsInTesting { + errorLogger = log.GetLogger(log.DEFAULT) + } + + xormEngine.AddHook(&ErrorQueryHook{ + Logger: errorLogger, + }) + + xormEngine.AddHook(&TracingHook{}) + + SetDefaultEngine(ctx, xormEngine) + return nil +} + +// SetDefaultEngine sets the default engine for db +func SetDefaultEngine(ctx context.Context, eng *xorm.Engine) { + x = eng + DefaultContext = &Context{ + Context: ctx, + e: x, + } +} + +// UnsetDefaultEngine closes and unsets the default engine +// We hope the SetDefaultEngine and UnsetDefaultEngine can be paired, but it's impossible now, +// there are many calls to InitEngine -> SetDefaultEngine directly to overwrite the `x` and DefaultContext without close +// Global database engine related functions are all racy and there is no graceful close right now. +func UnsetDefaultEngine() { + if x != nil { + _ = x.Close() + x = nil + } + DefaultContext = nil +} + +// InitEngineWithMigration initializes a new xorm.Engine and sets it as the db.DefaultContext +// This function must never call .Sync() if the provided migration function fails. +// When called from the "doctor" command, the migration function is a version check +// that prevents the doctor from fixing anything in the database if the migration level +// is different from the expected value. +func InitEngineWithMigration(ctx context.Context, migrateFunc func(*xorm.Engine) error) (err error) { + if err = InitEngine(ctx); err != nil { + return err + } + + if err = x.Ping(); err != nil { + return err + } + + preprocessDatabaseCollation(x) + + // We have to run migrateFunc here in case the user is re-running installation on a previously created DB. + // If we do not then table schemas will be changed and there will be conflicts when the migrations run properly. + // + // Installation should only be being re-run if users want to recover an old database. + // However, we should think carefully about should we support re-install on an installed instance, + // as there may be other problems due to secret reinitialization. + if err = migrateFunc(x); err != nil { + return fmt.Errorf("migrate: %w", err) + } + + if err = SyncAllTables(); err != nil { + return fmt.Errorf("sync database struct error: %w", err) + } + + for _, initFunc := range initFuncs { + if err := initFunc(); err != nil { + return fmt.Errorf("initFunc failed: %w", err) + } + } + + return nil +} + +// NamesToBean return a list of beans or an error +func NamesToBean(names ...string) ([]any, error) { + beans := []any{} + if len(names) == 0 { + beans = append(beans, tables...) + return beans, nil + } + // Need to map provided names to beans... + beanMap := make(map[string]any) + for _, bean := range tables { + beanMap[strings.ToLower(reflect.Indirect(reflect.ValueOf(bean)).Type().Name())] = bean + beanMap[strings.ToLower(x.TableName(bean))] = bean + beanMap[strings.ToLower(x.TableName(bean, true))] = bean + } + + gotBean := make(map[any]bool) + for _, name := range names { + bean, ok := beanMap[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return nil, fmt.Errorf("no table found that matches: %s", name) + } + if !gotBean[bean] { + beans = append(beans, bean) + gotBean[bean] = true + } + } + return beans, nil +} + +// DumpDatabase dumps all data from database according the special database SQL syntax to file system. +func DumpDatabase(filePath, dbType string) error { + var tbs []*schemas.Table + for _, t := range tables { + t, err := x.TableInfo(t) + if err != nil { + return err + } + tbs = append(tbs, t) + } + + type Version struct { + ID int64 `xorm:"pk autoincr"` + Version int64 + } + t, err := x.TableInfo(&Version{}) + if err != nil { + return err + } + tbs = append(tbs, t) + + if len(dbType) > 0 { + return x.DumpTablesToFile(tbs, filePath, schemas.DBType(dbType)) + } + return x.DumpTablesToFile(tbs, filePath) +} + +// MaxBatchInsertSize returns the table's max batch insert size +func MaxBatchInsertSize(bean any) int { + t, err := x.TableInfo(bean) + if err != nil { + return 50 + } + return 999 / len(t.ColumnsSeq()) +} + +// IsTableNotEmpty returns true if table has at least one record +func IsTableNotEmpty(beanOrTableName any) (bool, error) { + return x.Table(beanOrTableName).Exist() +} + +// DeleteAllRecords will delete all the records of this table +func DeleteAllRecords(tableName string) error { + _, err := x.Exec(fmt.Sprintf("DELETE FROM %s", tableName)) + return err +} + +// GetMaxID will return max id of the table +func GetMaxID(beanOrTableName any) (maxID int64, err error) { + _, err = x.Select("MAX(id)").Table(beanOrTableName).Get(&maxID) + return maxID, err +} + +func SetLogSQL(ctx context.Context, on bool) { + e := GetEngine(ctx) + if x, ok := e.(*xorm.Engine); ok { + x.ShowSQL(on) + } else if sess, ok := e.(*xorm.Session); ok { + sess.Engine().ShowSQL(on) + } +} + +type TracingHook struct{} + +var _ contexts.Hook = &TracingHook{} + +type sqlTask struct{} + +func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + ctx, task := trace.NewTask(c.Ctx, "sql") + ctx = context.WithValue(ctx, sqlTask{}, task) + trace.Log(ctx, "query", c.SQL) + trace.Logf(ctx, "args", "%v", c.Args) + return ctx, nil +} + +func (TracingHook) AfterProcess(c *contexts.ContextHook) error { + c.Ctx.Value(sqlTask{}).(*trace.Task).End() + return nil +} + +type SlowQueryHook struct { + Treshold time.Duration + Logger log.Logger +} + +var _ contexts.Hook = &SlowQueryHook{} + +func (SlowQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} + +func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.ExecuteTime >= h.Treshold { + h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) + } + return nil +} + +type ErrorQueryHook struct { + Logger log.Logger +} + +var _ contexts.Hook = &ErrorQueryHook{} + +func (ErrorQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} + +func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.Err != nil && !errors.Is(c.Err, context.Canceled) { + h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) + } + return nil +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-controls.py b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-controls.py new file mode 100644 index 00000000..6f5c170e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-controls.py @@ -0,0 +1,21 @@ +import pathlib,subprocess,json,hashlib,time +root=pathlib.Path('/tmp/apps-operation-controls-source-r1');target=root/'models/auth/source_operation.go';original=target.read_text() +runner=pathlib.Path('/tmp/run-apps-operation-tests-r1.py').read_text().replace('/tmp/apps-operation-backend-r1','/tmp/apps-operation-controls-source-r1').replace("'./models/auth','./models/db','-run','TestSourceOperation|TestSensitiveSQL'","'./models/auth','-run',TEST_FILTER") +controls=[('apply-cas','j.Phase != "prepared" || encodeSourceInventory(rows) != j.BeforeState','j.Phase != "prepared"','TestSourceOperationPrivateDriftRefuses/before-apply'),('inverse-cas','(j.Phase != "applied" && j.Phase != "unchanged") || encodeSourceInventory(rows) != j.PostState','(j.Phase != "applied" && j.Phase != "unchanged")','TestSourceOperationPrivateDriftRefuses/before-inverse'),('linked-delete','if count != 0 {','if false && count != 0 {','TestSourceOperationLinkedSourceRefusesDeletion'),('sqlite-first-write','''if _, err := db.GetEngine(tx).Exec(`UPDATE login_source_operation SET phase = phase WHERE operation_id = ''`); err != nil { + return err + }''','', 'TestSourceOperationDatabaseWriterExclusion')] +results=[] +try: + for label,old,new,filt in controls: + assert old in original,label + target.write_text(original.replace(old,new,1)) + script=runner.replace('TEST_FILTER',repr(filt)).replace('/tmp/apps-operation-tests-r1.log',f'/tmp/apps-operation-control-{label}.log').replace('/tmp/apps-operation-tests-r1.json',f'/tmp/apps-operation-control-{label}.json') + p=pathlib.Path(f'/tmp/run-apps-operation-control-{label}.py');p.write_text(script) + outcome=subprocess.run(['python3',str(p)],capture_output=True,text=True) + meta=json.loads(pathlib.Path(f'/tmp/apps-operation-control-{label}.json').read_text()) + actual_log=pathlib.Path(f'/tmp/apps-operation-control-{label}.log').read_text() + results.append({'test_assertion_failed':'--- FAIL: TestSourceOperation' in actual_log,'control':label,'expected':'test failure after guard removal','detected':meta['returncode']!=0 and '--- FAIL: TestSourceOperation' in actual_log,'returncode':meta['returncode'],'seconds':meta['seconds']}) + print(label,results[-1],flush=True) +finally: + target.write_text(original) + pathlib.Path('/tmp/apps-operation-controls-r1.json').write_text(json.dumps({'source_sha256':hashlib.sha256(original.encode()).hexdigest(),'controls':results},indent=2)) diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests-historical-net-only.py b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests-historical-net-only.py new file mode 100644 index 00000000..95aa52a3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests-historical-net-only.py @@ -0,0 +1,8 @@ +import os,pathlib,subprocess,json,time +r=pathlib.Path('/tmp/apps-operation-backend-r1') +env={'PATH':'/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin','HOME':'/tmp/operation-home','GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOMODCACHE':'/tmp/forgejo-independent-r1/gomodcache','GOCACHE':'/tmp/apps-operation-gocache-r1','GOPATH':'/tmp/operation-gopath','GOVCS':'*:off','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/tmp/operation-test.gitconfig','GIT_TERMINAL_PROMPT':'0','GOMAXPROCS':'4','CGO_ENABLED':'1'} +args=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--ro-bind','/tmp/forgejo-independent-r1/go','/tmp/forgejo-independent-r1/go','--ro-bind','/tmp/forgejo-independent-r1/gomodcache','/tmp/forgejo-independent-r1/gomodcache','--bind',str(r),str(r),'--bind','/tmp/apps-operation-gocache-r1','/tmp/apps-operation-gocache-r1','--chdir',str(r),'--','env','-i']+[k+'='+v for k,v in env.items()]+['go','test','-p','4','-tags','sqlite sqlite_unlock_notify','./models/auth','./models/db','-run','TestSourceOperation|TestSensitiveSQLHooks','-count=1','-v'] +start=time.time() +with open('/tmp/apps-operation-tests-r1.log','w') as out: p=subprocess.run(args,stdout=out,stderr=subprocess.STDOUT) +pathlib.Path('/tmp/apps-operation-tests-r1.json').write_text(json.dumps({'argv':args,'returncode':p.returncode,'seconds':time.time()-start},indent=2)) +print(p.returncode);print(pathlib.Path('/tmp/apps-operation-tests-r1.log').read_text()[-9000:]) diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests.py b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests.py new file mode 100644 index 00000000..a308b8c8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/run-tests.py @@ -0,0 +1,8 @@ +import os,pathlib,subprocess,json,time +r=pathlib.Path('/tmp/apps-operation-backend-r1') +env={'PATH':'/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin','HOME':'/tmp/operation-home','GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOMODCACHE':'/tmp/forgejo-independent-r1/gomodcache','GOCACHE':'/tmp/apps-operation-gocache-r1','GOPATH':'/tmp/operation-gopath','GOVCS':'*:off','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/tmp/operation-test.gitconfig','GIT_TERMINAL_PROMPT':'0','GOMAXPROCS':'4','CGO_ENABLED':'1'} +args=['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--ro-bind','/tmp/forgejo-independent-r1/go','/tmp/forgejo-independent-r1/go','--ro-bind','/tmp/forgejo-independent-r1/gomodcache','/tmp/forgejo-independent-r1/gomodcache','--bind',str(r),str(r),'--bind','/tmp/apps-operation-gocache-r1','/tmp/apps-operation-gocache-r1','--chdir',str(r),'--','env','-i']+[k+'='+v for k,v in env.items()]+['go','test','-p','4','-tags','sqlite sqlite_unlock_notify','./models/auth','./models/db','-run','TestSourceOperation|TestSensitiveSQL','-count=1','-v'] +start=time.time() +with open('/tmp/apps-operation-tests-r1.log','w') as out: p=subprocess.run(args,stdout=out,stderr=subprocess.STDOUT) +pathlib.Path('/tmp/apps-operation-tests-r1.json').write_text(json.dumps({'argv':args,'returncode':p.returncode,'seconds':time.time()-start},indent=2)) +print(p.returncode);print(pathlib.Path('/tmp/apps-operation-tests-r1.log').read_text()[-9000:]) diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/source-inventory.json b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/source-inventory.json new file mode 100644 index 00000000..903246af --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/source-inventory.json @@ -0,0 +1,55 @@ +{ + "source": "actual reviewed Forgejo 11.0.3 extended source at /tmp/forgejo-independent-r1/forgejo", + "candidate_root": "/tmp/apps-operation-backend-r1", + "paths": [ + { + "path": "models/auth/source_operation.go", + "before_sha256": null, + "after_sha256": "ef1b07ef6cf454015d00cde8588835f5118d829da91eb0303d37184859345a31", + "preimage_absent": true + }, + { + "path": "models/auth/source_operation_test.go", + "before_sha256": null, + "after_sha256": "77d311bca42f48faf3fbdc15c53582e3d8a6d9108a72ed7bb6172e5b88951428", + "preimage_absent": true + }, + { + "path": "models/auth/source_operation_internal_test.go", + "before_sha256": null, + "after_sha256": "af0f424d3a1fd2b29793b64baa1fa07284fee7ea0b7721c503db4efac0d871cb", + "preimage_absent": true + }, + { + "path": "models/auth/source_operation_process_test.go", + "before_sha256": null, + "after_sha256": "40d33b11a01cb659f0bf6f8b545c4d79910e8c082a789060bcbc9cadeee965bd", + "preimage_absent": true + }, + { + "path": "models/db/engine.go", + "before_sha256": "774cedc74e1a8d9ae6f9e5f106c6cbb66f12c11b42e99ef07a7affd03269c3d2", + "after_sha256": "43683804d0c5bc6ead1dba0344c69dd196825cf3d5eb502af8d932a7b8207b96", + "preimage_absent": false + }, + { + "path": "models/db/context.go", + "before_sha256": "10774e272206c7a8d6ebfb106b37d535de77f7e96ba57657b4dad960639f8519", + "after_sha256": "d4c4acfcb8987071aecf5cd957b3821c8d2619a2696398d37c2970f2527686f7", + "preimage_absent": false + }, + { + "path": "models/db/sensitive_sql.go", + "before_sha256": null, + "after_sha256": "8d4e6b3f08e9fdd919e97e47bd857cbd8935fdcbf367d20b5b8c545f279e796e", + "preimage_absent": true + }, + { + "path": "models/db/sensitive_sql_test.go", + "before_sha256": null, + "after_sha256": "de6353c955047c3311747e4777b9f9b2195e9efb19160b971dd758dfc94cf31b", + "preimage_absent": true + } + ], + "authority": "source-only low-level private database primitives; no exposed runtime/admission/backend completion" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/README.md b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/README.md new file mode 100644 index 00000000..3bd5e77e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/README.md @@ -0,0 +1,75 @@ +Apps release source r1 requires one source correction before integration. Its +`tools/forgejo-release-build.py:46` build sandbox mounts `/proc` without a PID +namespace. A process running through the exact shipped `sandbox()` can read an +unrelated process's command line, even though that process and its arguments are +outside the build workspace. This breaks the intended separation from ambient +credential-bearing processes. No real credential or host process command line +was inspected: the reproducer ran inside an outer PID/network namespace and +read only a synthetic helper's public canary. Adding only `--unshare-pid` made +the identical read return `DENIED`. The parent-root filesystem traversal control +returned permission denied; no filesystem escape is claimed. + +The exact adverse evidence is `proc-boundary-result.json`, with the executable +reproducer and invocation in `proc-boundary-test.py` and +`proc-argv-execution.json`. The synthetic helper exited normally and every owned +execution handle is terminal. The narrow requested correction is PID isolation +in `sandbox()`. This review does not modify the proposal or its owner source. + +All other reviewed build/artifact checks passed. The input patch is +`f0bcb2903592874d355e2a403de0c22aeda79b00070f30e7ea02b45106dd67fa`; +the frozen map is +`ec499038fa9a1e612bb81ed63bd67dad2ca52a178bb7bcb89a33bb64832880bb`. +All 71 map entries match current bytes. Applying the patch to the exact canonical +Makefile preimage produces exactly the six frozen postimages. Canonical Apps +remains `944b30ad63ab054cc24617e374e177d34cd572da`, its Makefile and both deployed +values files match the source inventory, and the five proposed added paths are +absent there. Source, Go and Node archives match the lock; source PAX commit and +Node's retained official checksum text agree. The accepted prior independent +review supplies the source/Go upstream-origin checks; no network was used here. + +Independent offline checks rehashed both existing source workspaces: each has +628 identical frontend assets, four identical bindata files, the seven exact +previously reviewed source outputs, preserved dependency locks, and the same +110,936,928-byte executable, SHA256 +`4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4`. +A fresh execution of the exact candidate's verifier in nested PID/network +isolation extracted all 2,234 public/options/template assets with exact byte +equality, exposed the new CLI flag, and found no ELF interpreter. The executable +sandbox had only the binary and empty output directory; no source/assets/library +fallback could satisfy that test. Seven independent adverse controls refused +outside-/tmp workspaces, existing workspaces, bad archives, repeated extraction, +changed dependency locks, changed reviewed source and changed container binary. + +OCI inspection used only a read-only archive, without Docker or a registry. +Every descriptor size, compressed blob digest and uncompressed diff_id matches; +the complete original public base layer descriptor prefix is exact, rootless +runtime configuration and labels agree, and the final layer contains only the +expected directories and exact executable with uid/gid 0 and mode0755. +Archive SHA256 is +`e0e74653321383e133485191f0ac5af53aafb065fb5809e20ede2808cec91539`, +manifest SHA256 is +`9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96`, +and config SHA256 is +`e9e81da43c5ea6895a95b6304337a40874e3275833be5f21fc6ffc104f79a2af`. +The two full builds and two OCI builds remain recorded producer evidence; this +review rechecked their retained outputs and did not repeat expensive builds. + +The initial reviewer harness incorrectly required every final-layer timestamp +to equal the image epoch. BuildKit correctly retains older source/base times and +clips later timestamps to the epoch. That harness failure, original script and +result are retained as attempt1. The corrected test checks the source binary's +exact source epoch and each entry's upper bound; attempt2 passes. This is a +reviewer test refinement, with no candidate correction or extra build success. +The later proc controls are a separate test of the same exact source revision. + +This packet is an independent technical source review, not owner assent, +architecture acceptance, image admission or deployment authorization. Existing +operational gaps remain: authenticated Access Engine decision enforcement, +private runtime secret input (the proposal is unimplemented), owner integration +and image admission/publication, exact type/case/descriptor re-freeze, current +operation/inverse sessions, durable authenticated journal, authoritative persisted +application before/post/current state, all release/application writer exclusion, +browser/group/login probes, capacity and rollout. Helm omission or rollback +does not delete persisted OAuth sources; image downgrade before verified inverse +is unsafe. Linked users or external logins must not be removed to force inverse. +These remain existing T34/T37 boundaries, not new gates introduced by this review. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/SHA256SUMS.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/SHA256SUMS.json new file mode 100644 index 00000000..381b8234 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/SHA256SUMS.json @@ -0,0 +1,31 @@ +{ + "README.md": "2f1643212661b1604f9be35300fdc9368b5f8256c94b38b201d35c77d95e27cf", + "controls/archive-tamper.log": "a6eaa5d1f58cf23abd7627f354b782abd98beaab27a8a8bb0d2d876a457823e9", + "controls/binary-change-before-container.log": "e54cbd65d7abda0f1eb0d1c3c942df586dd90046f746081f1ed7ce77f8a9dee8", + "controls/dependency-lock-tamper.log": "4e36b2c28547e306c4e1a69fbc9ebe032ffc282dad8067324f582bee792b20ee", + "controls/existing-workspace.log": "7a09f8a50ea0374f2471bd6e19c6f63c6d9396c6e11adcea25e908c4a9d883bd", + "controls/outside-tmp.log": "15e2ff62c356b04f22bae120faaabb190f40a4b57911971df0e406edbd0ff096", + "controls/repeat-verification.log": "248f3eccad77673747a6bc7d0a746a770c9d00e953bc3e7a9d0d43ed19c9dfb9", + "controls/reviewed-source-tamper.log": "c8a263c96c7e60b8f62e0a995c4a45a61db814860670870b91add69900543467", + "final-input-check.json": "64901fa6e0d6bff50983106605e0a2f690b93c04a5149bb0a9c9f8668b2229d3", + "fresh-process.json": "5bdb80885c005f9886e29a10c579f9f15cf2653d4a1b1259e4ec799e84261645", + "fresh-verification.json": "cd9b24143d73231cc2029b8a8788d32f677fd51255953da6c1aed3d907ed7dca", + "offline-attempt1.json": "b6d6171abf3221dd2da0b79b9631076258a3e638555e3fb2d4f796a53490d8bf", + "offline-attempt1.log": "617277c76cf8c6bbb05100f8ca0f5efd72e77a8c5337a974f70185c292db1b7b", + "offline-attempt2.json": "a04e84668beef61b1ae62093514204bbed3ebb939f13b3461641dedba8b5c009", + "offline-attempt2.log": "0838feceb8317e6e19d3d398fe1476de356ac7aa9ab7348b158406f8b5c28dd4", + "offline-results-attempt1.json": "6eb2430dd38d572abb2a579f2ffd508ad8720e53e7d41dbb7f077178290ca35c", + "offline-results.json": "fb6da7aaaf43fe613d27a025a74ca42e747e7bb5ddd658b62ed7a8a1f3ba3383", + "proc-argv-execution.json": "e32c97e8e56229be96a4e69ddb1d8c47028d876fef2b61e79532e6fc9e26811e", + "proc-argv.log": "d8c7d4ec492dcfbd4bb1584dff74da9c05ec13a730fb58b09138f33d4db00b94", + "proc-boundary-execution.json": "a75dd2e210c88c343b8bfca82f4ca40c7402cf32cbc0f50e5ff8e985db1e9c33", + "proc-boundary-result-attempt1.json": "57878fb236c8c5253cfe5424f7ff136397c0cc08ff700ab0bffcef1e963ac23d", + "proc-boundary-result.json": "d8c7d4ec492dcfbd4bb1584dff74da9c05ec13a730fb58b09138f33d4db00b94", + "proc-boundary-test.py": "45eb2d6eab745ad4e7bfd5a1fc0b33e0757cab20f314b45aaeca15afe76b8703", + "proc-boundary.log": "57878fb236c8c5253cfe5424f7ff136397c0cc08ff700ab0bffcef1e963ac23d", + "public-archives.json": "e67bdfe41e887d380b307e065258838f8c6e2891f63ff842d6a934326f6410f6", + "review-offline-attempt1.py": "1800ca5f24898e3e6fe993461ba50a78ed6bceebe4bc32d87d5527e224330438", + "review-offline.py": "2d4c6f0160eae873fa475042f99f183a818c06925f00de789c5650defb874712", + "review.json": "df2c23df953e1d4c10bd3abd7aea350cc33d8bbfdb2a99d92a436feba0946f73", + "status.json": "3c9427425181504207e5496f52a42aac231b35d413cb1ca70016778fd3da3451" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/final-input-check.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/final-input-check.json new file mode 100644 index 00000000..1b0ff38b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/final-input-check.json @@ -0,0 +1,15 @@ +{ + "apps_head": "944b30ad63ab054cc24617e374e177d34cd572da", + "canonical_files": { + "Makefile": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290", + "helm/forgejo-values.yaml": "cf87a3dccd666bf8870619755afac4af64fc0bee77b0f0ea54718d6384b70d4f", + "helm/forgejo-registry-values.yaml": "a38f551734843c6367448f267c7376b1aeee010927a49690fdd7c1471e0ae450" + }, + "Makefile_preimage_equals_canonical_HEAD_and_worktree": true, + "five_added_paths_absent_in_canonical_owner": true, + "all_frozen_entries_still_exact": 71, + "patch_sha256": "f0bcb2903592874d355e2a403de0c22aeda79b00070f30e7ea02b45106dd67fa", + "map_sha256": "ec499038fa9a1e612bb81ed63bd67dad2ca52a178bb7bcb89a33bb64832880bb", + "canonical_owner_changed_by_reviewer": false, + "candidate_changed_by_reviewer": false +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-process.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-process.json new file mode 100644 index 00000000..e8180b86 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-process.json @@ -0,0 +1,65 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--unshare-pid", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--dir", + "/app", + "--ro-bind", + "/tmp/review/verification/source/gitea", + "/app/forgejo", + "--bind", + "/tmp/review/verification/embedded-extracted", + "/results", + "--chdir", + "/tmp", + "--clearenv", + "--setenv", + "PATH", + "/nonexistent", + "--setenv", + "HOME", + "/tmp/home", + "--setenv", + "USER", + "builder", + "--setenv", + "USERNAME", + "builder", + "--setenv", + "GITEA_WORK_DIR", + "/tmp/work", + "--setenv", + "GITEA_CUSTOM", + "/tmp/custom", + "--setenv", + "GITEA_APP_INI", + "/tmp/absent-app.ini", + "--setenv", + "TZ", + "UTC", + "--setenv", + "LC_ALL", + "C", + "--", + "/app/forgejo", + "embedded", + "extract", + "--include-vendored", + "--destination", + "/results", + "**" + ], + "driver_pid": null, + "phase": "embedded-extract", + "pid": null, + "returncode": 0, + "seconds": 1.91, + "state": "complete" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-verification.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-verification.json new file mode 100644 index 00000000..c069f058 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/fresh-verification.json @@ -0,0 +1,13 @@ +{ + "all_public_options_templates_exact": true, + "architecture_approval": false, + "binary_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "embedded_file_manifest_sha256": "74a0ec278ab99ac86ee434d7620229dc4a70146efd51876a1e4738d2c9f4ffdb", + "embedded_files": 2234, + "native_admission": false, + "network": "bwrap --unshare-net", + "new_cli_flag_present": true, + "no_dynamic_interpreter": true, + "no_source_assets_libraries_or_host_config_mounted": true, + "source_locks_preserved": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt1.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt1.json new file mode 100644 index 00000000..4803f772 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt1.json @@ -0,0 +1,68 @@ +{ + "argv": [ + "/usr/bin/bwrap", + "--unshare-net", + "--unshare-pid", + "--new-session", + "--die-with-parent", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--bind", + "/tmp/apps-release-independent-review-r1-hkx9xvc0", + "/tmp/review", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1", + "/proposal", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1", + "/prior-review", + "--ro-bind", + "/tmp/apps-forgejo-release-build-r1/source", + "/source1", + "--ro-bind", + "/tmp/apps-forgejo-release-repro-r1/source", + "/source2", + "--ro-bind", + "/tmp/apps-forgejo-release-repro-r1/image.oci.tar", + "/image.oci.tar", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/review-offline.py", + "/review-offline.py", + "--chdir", + "/tmp/review", + "--clearenv", + "--setenv", + "PATH", + "/usr/bin:/bin", + "--setenv", + "HOME", + "/tmp/home", + "--", + "/usr/bin/python3", + "-I", + "/review-offline.py" + ], + "returncode": 1, + "elapsed_seconds": 6.03, + "owned_pid_terminal": true, + "workspace": "/tmp/apps-release-independent-review-r1-hkx9xvc0", + "network": "unshare-net", + "pid": "unshare-pid", + "host_home_credentials_config_mounted": false +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt2.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt2.json new file mode 100644 index 00000000..615970ee --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-attempt2.json @@ -0,0 +1,69 @@ +{ + "argv": [ + "/usr/bin/bwrap", + "--unshare-net", + "--unshare-pid", + "--new-session", + "--die-with-parent", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--bind", + "/tmp/apps-release-independent-review-r1-attempt2-86saom6w", + "/tmp/review", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1", + "/proposal", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1", + "/prior-review", + "--ro-bind", + "/tmp/apps-forgejo-release-build-r1/source", + "/source1", + "--ro-bind", + "/tmp/apps-forgejo-release-repro-r1/source", + "/source2", + "--ro-bind", + "/tmp/apps-forgejo-release-repro-r1/image.oci.tar", + "/image.oci.tar", + "--ro-bind", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/review-offline.py", + "/review-offline.py", + "--chdir", + "/tmp/review", + "--clearenv", + "--setenv", + "PATH", + "/usr/bin:/bin", + "--setenv", + "HOME", + "/tmp/home", + "--", + "/usr/bin/python3", + "-I", + "/review-offline.py" + ], + "returncode": 0, + "elapsed_seconds": 11.08, + "owned_pid_terminal": true, + "workspace": "/tmp/apps-release-independent-review-r1-attempt2-86saom6w", + "network": "unshare-net", + "pid": "unshare-pid", + "host_home_credentials_config_mounted": false, + "refinement": "Reviewer-only timestamp upper bound correction; attempt1 preserved, no candidate correction or repeated full build." +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-results-attempt1.json b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-results-attempt1.json new file mode 100644 index 00000000..44b5d800 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-release-independent-review-r1/offline-results-attempt1.json @@ -0,0 +1,95 @@ +{ + "adversarial_refusals": { + "detail": { + "controls": [ + { + "case": "outside-tmp", + "refused": true, + "returncode": 1 + }, + { + "case": "existing-workspace", + "refused": true, + "returncode": 1 + }, + { + "case": "archive-tamper", + "refused": true, + "returncode": 1 + }, + { + "case": "repeat-verification", + "refused": true, + "returncode": 1 + }, + { + "case": "dependency-lock-tamper", + "refused": true, + "returncode": 1 + }, + { + "case": "reviewed-source-tamper", + "refused": true, + "returncode": 1 + }, + { + "case": "binary-change-before-container", + "refused": true, + "returncode": 1 + } + ], + "no_build_container_or_network_action_in_controls": true + }, + "passed": true + }, + "artifact_reproduction_current_bytes": { + "detail": { + "binary_equal": true, + "bindata_sources_each": 4, + "frontend_files_each": 628, + "public_integrity_bound_npm_packages": 1150 + }, + "passed": true + }, + "fresh_bare_binary_asset_extraction": { + "detail": { + "all_public_options_templates_exact": true, + "architecture_approval": false, + "binary_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "embedded_file_manifest_sha256": "74a0ec278ab99ac86ee434d7620229dc4a70146efd51876a1e4738d2c9f4ffdb", + "embedded_files": 2234, + "native_admission": false, + "network": "bwrap --unshare-net", + "new_cli_flag_present": true, + "no_dynamic_interpreter": true, + "no_source_assets_libraries_or_host_config_mounted": true, + "source_locks_preserved": true + }, + "passed": true + }, + "frozen_packet": { + "detail": { + "exact_patch_and_map": true, + "frozen_entries": 71 + }, + "passed": true + }, + "independent_oci_structure_and_content": { + "error": "AssertionError()", + "passed": false + }, + "patch_pre_postimages": { + "detail": { + "exact_six_postimages": 6, + "no_other_paths_created": true + }, + "passed": true + }, + "reviewed_source_binding": { + "detail": { + "both_workspace_outputs_current": true, + "prior_accepted_extension_bound": true + }, + "passed": true + } +}