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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/native-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@ jobs:
node scripts/test-native-report-fixture.cjs
node scripts/test-native-fixture-paths.cjs
- name: Stage pinned official runtime and labeled fixtures
run: python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native"
run: |
test "$(git rev-parse HEAD)" = "$QUALIFIED_SOURCE_SHA"
echo "QUALIFIED_SOURCE_TREE=$(git rev-parse HEAD^{tree})" >> "$GITHUB_ENV"
python3 scripts/prepare-native-inputs.py --output "$RUNNER_TEMP/vmpower-native"
- name: Stage exact retained public Debian package families before quarantine
run: python3 scripts/prepare-legacy-package-inputs.py --output "$RUNNER_TEMP/vmpower-native"
- name: Isolated systemd lifecycle and progress
run: |
sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,ImageOS,ImageVersion,RUNNER_TEMP \
sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT,GITHUB_JOB,GITHUB_REPOSITORY,QUALIFIED_SOURCE_SHA,QUALIFIED_SOURCE_TREE,ImageOS,ImageVersion,RUNNER_TEMP \
python3 scripts/linux-package-quarantine.py --output "$RUNNER_TEMP/vmpower-native" --legacy-dpkg
- name: Always reconcile owned fixture cleanup
if: always()
Expand All @@ -61,6 +64,18 @@ jobs:
if-no-files-found: ignore
retention-days: 3

- name: Reopen retained final package after successful lifecycle and cleanup
run: python3 -B scripts/qualify-linux-dpkg.py --verify-retained "$RUNNER_TEMP/vmpower-native"
- name: Retain exact qualified final Linux package only
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: final-linux-package-${{ env.QUALIFIED_SOURCE_SHA }}
path: |
${{ runner.temp }}/vmpower-native/evidence/final-linux/vmpower-agent_0.2.0_amd64.deb
${{ runner.temp }}/vmpower-native/evidence/final-linux/manifest.json
if-no-files-found: error
retention-days: 3

windows-native:
runs-on: windows-2025
timeout-minutes: 20
Expand Down
36 changes: 32 additions & 4 deletions distribution/linux-debian-handoff.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Linux Debian ownership and rollback candidate

This source prepares one package builder and the actual maintainer hooks for a
future release. It is not published or customer-qualified. The optional native
gate below has not run. The current public binaries remain unchanged.
future release. It is not published or customer-qualified. The final-version
retention extension below has not run. The current public binaries remain unchanged.

The two cached public Linux packages are both `vmpower-agent` version `0.1`, with
different executable bytes. They own `/usr/bin/vmpower`,
Expand Down Expand Up @@ -101,8 +101,36 @@ before any push; this source preparation has not dispatched CI.
that registration **before dropping quarantine**, verifies absence, stops the
exact watchdog, removes owned state, stops all owned
processes/receivers, removes firewall rules and restores the original `/opt`
permissions. Upload sanitized lifecycle/cleanup JSON only; no packages,
configuration bytes, raw environments or snapshots.
permissions. Upload sanitized lifecycle/cleanup JSON and, only after successful
qualification, the final package described below. Exclude synthetic packages,
configuration bytes, raw environments and snapshots.

## Final-version retention prerequisite

The Linux job additionally builds `vmpower-agent_0.2.0_amd64.deb` from the original
unmodified compiled bundle, never the synthetic upgrade/crash variant. Its source
commit/tree, run/attempt, original pinned Node archive, application lock, bundle
manifest and included licenses are bound in a sanitized manifest. Before each
actual final-package dpkg install, a separate read requires a nonzero bounded
regular single-link archive, computes its size and SHA256, reopens its control/data
archives, and compares every payload, hook and mode with the original inputs.
Neither a builder receipt nor a zero-byte metadata field substitutes for this read.

After all existing adverse phases, each exact cached legacy family is installed
again without executing Node10. The same retained final archive must upgrade it,
preserve config bytes and stopped/disabled state, own the package paths, run as the
service user and produce two reports before purge preserves config. This adds two
existing bounded 85-second report waits; the 750-second scope and 15-minute job
limits are unchanged. Actual runtime fit remains an execution prerequisite.

Only successful lifecycle, both final-family report proofs, supervisor and cleanup
receipts, removed isolation, and a final independent archive reopen/hash allow the
two exact files under `evidence/final-linux/` to upload for three days. No wildcard
package upload, release, tag, public consumer URL or publishing action is added.
This prepares retention; only an admitted native run can produce the actual package
and lifecycle evidence. Post-success rollback remains **UNQUALIFIED**: the adverse
fixture's failed-upgrade compensation is not proof of recovery after successful
customer installation. Customer OS compatibility remains Unknown.

Expected incremental gate work is two package-family matrices within the existing
15-minute Linux job. The two-report waits alone need about 60 seconds per family;
Expand Down
11 changes: 7 additions & 4 deletions distribution/native-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ Root review and CI/budget admission precede a push. The prepared workflow
`.github/workflows/native-qualification.yml` has one standard Ubuntu 24.04 x64 job
(15 minutes) and one standard Windows 2025 x64 job (20 minutes). It runs on this
delivery branch/master pushes and master PRs, with read-only contents permission,
no customer/provider secrets, no dispatch/release/tag event and no binary artifact
publishing. Existing source/CodeQL jobs remain separate and count toward admission.
Only sanitized JSON qualification receipts are uploaded for three days; package
bytes, synthetic configuration, login rows and raw service environments are excluded.
no customer/provider secrets and no dispatch/release/tag event or release publishing.
Existing source/CodeQL jobs remain separate and count toward admission. Sanitized
JSON qualification receipts are uploaded for three days. After successful Linux
lifecycle and cleanup, a separate three-day artifact retains only the exact final
`vmpower-agent_0.2.0_amd64.deb` and its sanitized provenance manifest; the success-only
gate reopens and hashes that archive again. Synthetic package variants,
configuration, login rows and raw service environments are excluded.
Download only the pinned public Node and NSSM inputs and public npm packages before
enforcing outbound isolation. Record exact runner OS, kernel/libc or Windows build
and PowerShell version. These results do not establish Windows 10/Server 2016,
Expand Down
58 changes: 58 additions & 0 deletions scripts/build-linux-deb.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import os
from pathlib import Path
import re
import stat
import sys
import tarfile

Expand Down Expand Up @@ -85,6 +86,63 @@ def build(bundle,expected,version,output):
return {'package':'vmpower-agent','version':version,'architecture':'amd64','sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw),'bundleManifestSha256':expected,'maintainerHookSha256':hashlib.sha256(controls['postinst'][0]).hexdigest(),'files':len(payload)}


def read_package(path):
"""Reopen our fixed three-member deb without extracting or executing anything."""
before=path.lstat()
if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or not 0<before.st_size<=160*1024*1024:
raise ValueError('Bounded nonempty single-link package required')
with os.fdopen(os.open(path,os.O_RDONLY|os.O_NOFOLLOW),'rb') as handle:
opened=os.fstat(handle.fileno())
if (opened.st_dev,opened.st_ino)!=(before.st_dev,before.st_ino):raise ValueError('Package changed while opening')
raw=handle.read(160*1024*1024+1)
after=os.fstat(handle.fileno())
current=path.lstat()
if len(raw)!=before.st_size or any((s.st_dev,s.st_ino,s.st_size,s.st_mtime_ns)!=(before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns) for s in (after,current)):
raise ValueError('Package changed while reading')
if raw[:8]!=b'!<arch>\n':raise ValueError('Invalid deb archive')
position=8;members={}
for name in ('debian-binary','control.tar.gz','data.tar.gz'):
header=raw[position:position+60]
try:size=int(header[48:58])
except ValueError:raise ValueError('Invalid deb member size') from None
expected=f'{name+"/":<16}{0:<12}{0:<6}{0:<6}{"100644":<8}{size:<10}`\n'.encode()
if header!=expected or not 0<size<=len(raw)-position-60:raise ValueError('Invalid deb member')
members[name]=raw[position+60:position+60+size];position+=60+size
if size%2:
if raw[position:position+1]!=b'\n':raise ValueError('Invalid deb padding')
position+=1
if position!=len(raw) or members['debian-binary']!=b'2.0\n':raise ValueError('Unexpected deb members')
archives={}
for name in ('control.tar.gz','data.tar.gz'):
files={};seen=set();total=0
with gzip.GzipFile(fileobj=io.BytesIO(members[name])) as compressed:
tar=compressed.read(512*1024*1024+1)
if len(tar)>512*1024*1024:raise ValueError('Uncompressed archive bound exceeded')
with tarfile.open(fileobj=io.BytesIO(tar),mode='r:') as archive:
for entry in archive:
total+=entry.size
if len(seen)>=20000 or entry.size<0 or total>512*1024*1024 or entry.name in seen:raise ValueError('Archive bounds or duplicate member')
seen.add(entry.name)
if not entry.name.startswith('./') or any(x in ('','..','.') for x in entry.name[2:].split('/')) or entry.uid or entry.gid or entry.mtime:
raise ValueError('Unexpected archive metadata')
if entry.isdir():
if entry.mode!=0o755 or entry.size:raise ValueError('Unexpected directory metadata')
elif entry.isfile():files[entry.name[2:]]=(archive.extractfile(entry).read(),entry.mode)
else:raise ValueError('Package links or special files refused')
expected_names={'./'+f for f in files}|{'./'+str(parent) for f in files for parent in Path(f).parents if str(parent)!='.'}
if seen!=expected_names:raise ValueError('Unexpected archive directory inventory')
archives[name]=files
return {'sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw)},archives


def verify_package(path,bundle,expected,version,receipt):
measured,archives=read_package(path)
if measured!={k:receipt[k] for k in ('sha256','bytes')}:raise ValueError('Retained package size/hash mismatch')
payload,controls=package_files(bundle,expected,version)
if archives!={'data.tar.gz':payload,'control.tar.gz':controls}:raise ValueError('Reopened package differs from qualified inputs')
return {**measured,'archiveReopened':True,'exactControlAndData':True,'files':len(payload)}


if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--bundle',type=Path,required=True);parser.add_argument('--expected-manifest-sha256',required=True);parser.add_argument('--version',required=True);parser.add_argument('--output',type=Path,required=True);args=parser.parse_args()
print(json.dumps(build(args.bundle,args.expected_manifest_sha256,args.version,args.output)))
3 changes: 2 additions & 1 deletion scripts/linux-package-quarantine.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def __init__(self,reason,resource='local',query=None):

def failure_record(exc,phase):
reason=(exc.reason if isinstance(exc,AdmissionRefused) else
'QUERY_FAILED' if isinstance(exc,commands.CommandFailure) else
'OWNED_SCOPE_FAILED' if type(exc) is commands.CommandFailure and exc.record.get('operation')=='OWNED_SCOPE' else
'COMMAND_FAILED' if isinstance(exc,commands.CommandFailure) else
'SIGNAL' if isinstance(exc,commands.Interrupted) else
'IO_FAILED' if isinstance(exc,OSError) else 'UNKNOWN')
return {'phase':phase,'resource':exc.resource if isinstance(exc,AdmissionRefused) else 'local','reason':reason,
Expand Down
Loading
Loading