Release v0.1.9: enforce Photos API boundary and finalize Server migration

This commit is contained in:
渡口浪人
2026-10-17 01:39:23 +08:00
parent 46a0429d5e
commit b87bf6f77e
35 changed files with 724 additions and 155 deletions
+10 -24
View File
@@ -70,6 +70,13 @@ class Deploy:
return json.loads(self.run("inspect", name).stdout)[0]
def settings(self):
if any(
self.env.get(key, "").startswith("/volume2/docker/pigway-frame-flow")
for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR")
):
raise ValueError(
"NAS Frame deployment is retired; target the Ubuntu Server"
)
missing = [key for key in REQUIRED if not self.env.get(key)]
if missing:
raise ValueError("Missing Variables: " + ", ".join(missing))
@@ -118,20 +125,9 @@ class Deploy:
or not m.get("RW")
):
raise ValueError("Production mount mismatch: " + key)
photos = self.env.get("PROD_PHOTOS_DIR")
if "/photos" in mounts and (
not photos
or mounts["/photos"]["Source"] != photos
or mounts["/photos"]["RW"]
):
raise ValueError(
"Confirm PROD_PHOTOS_DIR for the existing read-only photo mount"
)
if photos and "/photos" not in mounts:
raise ValueError(
"Configured photos directory does not match current container"
)
if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()):
if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts:
raise ValueError("Filesystem photo mounts are retired; use the Photos API")
if set(mounts) != {"/data", "/config"}:
raise ValueError("Unrecognized persistent mount; review before adopting CD")
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
if bindings != [
@@ -180,16 +176,6 @@ class Deploy:
)
]
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
if photos:
service["volumes"].append(
{
"type": "bind",
"source": photos,
"target": "/photos",
"read_only": True,
"bind": {"create_host_path": False},
}
)
# Adoption must preserve runtime constraints, not silently change them.
if (
old["Config"]["User"] != service["user"]
+244
View File
@@ -0,0 +1,244 @@
"""Release a verified source bundle on the Ubuntu T7 host; never write to frames."""
import datetime
import fcntl
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import time
import urllib.request
from pathlib import Path
APP = Path("/srv/pigway/apps/pigway-frame-flow")
RECOVERY = Path("/srv/pigway/recovery/frame-flow")
CONTAINER = "pigway-frame-flow"
def run(*args):
return subprocess.check_output(args, text=True, timeout=600).strip()
def inspect():
return json.loads(run("docker", "inspect", CONTAINER))[0]
def archive_staging(backup, ctx):
# These are retired Frame-only inputs, never shared console migration files.
names = [
"frame-schedule-ui",
"frame-photos-ui",
"frame-final-status.sh",
"frame-compose.candidate.private.json",
"frame-fresh-compose.private.json",
"frame-image.tar",
"frame-prepare.sh",
"frame-nfs-setup.sh",
"frame.unit",
"compose.ubuntu-photos-api.yml",
]
archive = backup / "retired-frame-staging"
archive.mkdir(mode=0o700)
moved = []
for name in names:
source = ctx.parent / name
if source.exists():
assert not source.is_symlink(), name
source.rename(archive / name)
moved.append(name)
for p in archive.rglob("*"):
if not p.is_symlink():
p.chmod(0o700 if p.is_dir() else 0o600)
# Remove only disposable aliases, preserving every recovery reference and image.
references = "\n".join(
p.read_text(errors="replace")
for p in RECOVERY.rglob("*")
if p.is_file() and p.suffix in {".json", ".txt"}
)
removed = []
for tag in [
"pigway-frame-flow:history-ui-local-base",
"pigway-frame-flow:final-audit-local-base",
]:
tags = run(
"docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}"
).splitlines()
if tag in tags and tag not in references:
run("docker", "image", "rm", tag)
removed.append(tag)
return {
"archived_frame_inputs": moved,
"removed_temporary_aliases": removed,
"shared_inputs_and_recovery_preserved": True,
}
def verify_runtime(commit):
c = inspect()
env = dict(s.split("=", 1) for s in c["Config"]["Env"])
assert env["KOKONNA_MOCK"] == "false"
assert env["KOKONNA_WRITE_ENABLED"] == "true"
assert env["BUILD_COMMIT"] == commit
assert c["State"]["Running"]
assert c["Config"]["User"] == "10333:10333"
assert c["HostConfig"]["ReadonlyRootfs"]
assert set(c["HostConfig"]["CapDrop"]) == {"ALL"}
assert {m["Destination"] for m in c["Mounts"]} == {"/data", "/config"}
for mount in c["Mounts"]:
assert Path(mount["Source"]).resolve() == APP / mount["Destination"][1:]
return c
def main():
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
os.umask(0o077)
run("/usr/local/libexec/pigway-guard-mount")
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == (
"00000000-0000-4000-8000-000000000001"
)
with (RECOVERY / ".release.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
release(lock)
def release(lock):
ctx = Path(__file__).resolve().parent.parent
assert ctx.parent == Path("/srv/pigway/migration-inbox")
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
assert re.fullmatch("[0-9a-f]{40}", commit)
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
for name, expected in hashes.items():
p = (ctx / name).resolve()
assert p.is_relative_to(ctx)
assert hashlib.sha256(p.read_bytes()).hexdigest() == expected, name
before = inspect()
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
verify_runtime(old_env["BUILD_COMMIT"])
assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == (
(ctx / "requirements.lock").read_text().strip()
), "Dependencies differ; full build required"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
backup.mkdir(mode=0o700)
shutil.copy2(APP / "compose.json", backup / "compose.before.json")
shutil.copytree(APP / "config", backup / "config")
with sqlite3.connect(
"file:" + str(APP / "data/frameflow.db") + "?mode=ro", uri=True
) as src:
with sqlite3.connect(backup / "frameflow.before.db") as dst:
src.backup(dst)
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.9-" + commit[:12]
try:
run(
"docker",
"build",
"--pull=false",
"--network=none",
"-f",
str(ctx / "deploy/Dockerfile.runtime-update"),
"--build-arg",
"BASE_IMAGE=" + base,
"--build-arg",
"BUILD_COMMIT=" + commit,
"--build-arg",
"BUILD_TIME=" + stamp,
"-t",
image,
str(ctx),
)
finally:
run("docker", "image", "rm", base)
compose = json.loads((APP / "compose.json").read_text())
service = compose["services"][CONTAINER]
service["image"] = image
service["environment"]["BUILD_COMMIT"] = commit
service["environment"]["BUILD_TIME"] = stamp
changed = False
try:
changed = True
(APP / "compose.json").write_text(json.dumps(compose, indent=2) + "\n")
run("systemctl", "restart", "pigway-frame-flow.service")
for attempt in range(30):
try:
with urllib.request.urlopen(
"http://127.0.0.1:81/ready", timeout=4
) as r:
assert json.load(r)["status"] == "ready"
break
except Exception:
if attempt == 29:
raise
time.sleep(2)
current = verify_runtime(commit)
assert current["Image"] == run(
"docker", "image", "inspect", "--format", "{{.Id}}", image
)
# Verify all deployed project files, not just a version string or an image tag.
project_hashes = {
n: h
for n, h in hashes.items()
if n.startswith(("pigway_frame_flow/", "vendor/"))
or n == "requirements.lock"
}
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
subprocess.run(
["docker", "exec", "-i", CONTAINER, "python", "-c", code],
input=json.dumps(project_hashes),
text=True,
check=True,
timeout=60,
)
for name in [
"app.js",
"features.js",
"extensions.js",
"i18n.js",
"index.html",
"style.css",
]:
url = "http://127.0.0.1:81/" + (
"" if name == "index.html" else "ui/" + name
)
with urllib.request.urlopen(url, timeout=10) as response:
assert (
response.read()
== (ctx / "pigway_frame_flow/web" / name).read_bytes()
)
run("systemctl", "is-active", "pigway-frame-flow.service")
except BaseException:
if changed:
shutil.copy2(backup / "compose.before.json", APP / "compose.json")
run("systemctl", "restart", "pigway-frame-flow.service")
raise
shutil.copy2(APP / "compose.json", RECOVERY / "compose.json")
(RECOVERY / "image.txt").write_text(image + "\n")
shutil.copy2(ctx / "source.tar.gz", backup / "source.tar.gz")
cleanup = archive_staging(backup, ctx)
receipt = {
"result": "PASS",
"commit": commit,
"image": image,
"image_id": current["Image"],
"backup": str(backup),
"mock": False,
"write_enabled": True,
"checked_at": stamp,
"cleanup": cleanup,
}
output = APP / "release-verification.json"
output.write_text(json.dumps(receipt, indent=2) + "\n")
owner = ctx.stat()
os.chown(output, owner.st_uid, owner.st_gid)
shutil.copy2(output, backup / "release-verification.json")
shutil.rmtree(ctx)
print(json.dumps(receipt, indent=2))
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+28
View File
@@ -0,0 +1,28 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{state.templates=[{id:'daily-dashboard',name:'Dashboard',defaults:{}}];await jobEditor();});
for(const unit of ['hours','minutes']){
await p.locator('#job-schedule').selectOption(unit);await p.locator('#schedule-value').fill('2');
assert.equal(await p.locator('#schedule-value').getAttribute('type'),'number');
assert(await p.locator('#schedule-hint').innerText());
}
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await p.locator('#job-name').fill('Test interval');
await p.locator('#job-form button[type=submit]').click();await p.waitForTimeout(100);
assert(saved);assert.deepEqual(saved.schedule,{type:'interval',interval_minutes:2});
await p.evaluate(async()=>{await jobEditor({id:'existing',name:'Existing',schedule:{type:'interval',interval_minutes:120},source:{type:'template',template:'daily-dashboard',data:{}},enabled:false,display_duration:0,restore_previous:false,priority:50});});
assert.equal(await p.locator('#job-schedule').inputValue(),'hours');assert.equal(await p.locator('#schedule-value').inputValue(),'2');
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的相框'}];});assert.equal(await p.evaluate(()=>deviceDisplayName(state.devices[0])),'我的相框');assert.equal(await p.evaluate(()=>deviceDisplayName({id:'living-room',name:'living-room'})),lang==='zh'?'客厅':'Living room');await p.locator('#modal-cancel').click();await p.evaluate(async()=>{const previous=api;api=async()=>[{id:'fixture',name:'Family',device_id:'living-room',schedule:{type:'interval',interval_minutes:120},enabled:false}];try{await views.automation(state.epoch);}finally{api=previous;}});assert.equal(await p.locator('tbody td small').first().innerText(),'我的相框');assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+17
View File
@@ -0,0 +1,17 @@
const {chromium}=require('playwright');const fs=require('fs');const path=require('path');const assert=require('assert');
const root=path.resolve(__dirname,'../pigway_frame_flow/web');const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let count=0;
try{for(const width of [1440,768,390,320])for(const lang of ['zh','en'])for(const theme of ['dark','light']){
const p=await b.newPage({viewport:{width,height:900}});const errors=[];p.on('pageerror',e=>errors.push(e.message));
await p.addInitScript(({lang,theme})=>{localStorage.setItem('ff-language',lang);localStorage.setItem('ff-theme',theme)},{lang,theme});
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){if(u.pathname==='/api/v1/version')return r.fulfill({json:{version:'0.1.9',commit:'ba9424a-ui',build_time:'validation'}});return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}let f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');const file=path.join(root,f);if(!file.startsWith(root)||!fs.existsSync(file))return r.fulfill({status:404,body:''});return r.fulfill({body:fs.readFileSync(file),contentType:f.endsWith('.css')?'text/css':f.endsWith('.js')?'application/javascript':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.waitForTimeout(60);
assert.equal(await p.locator('#device option:checked').innerText(),lang==='zh'?'客厅':'Living room');
const layout=await p.evaluate(()=>{const q=s=>document.querySelector(s).getBoundingClientRect();return {overflow:document.documentElement.scrollWidth>innerWidth,card:q('.login-card').toJSON(),form:q('#login-form').toJSON(),user:q('#username').toJSON(),password:q('#password').toJSON(),remember:document.querySelector('#remember-login').closest('label').getBoundingClientRect().toJSON(),button:q('#login-form button').toJSON(),label:document.querySelector('#remember-login').labels[0].innerText}});
assert(!layout.overflow,JSON.stringify({width,lang,theme,layout}));assert(layout.password.top-layout.user.bottom>=24);assert(layout.button.top-layout.remember.bottom>=20);assert(layout.button.width>=layout.form.width-1);assert.equal(errors.length,0,errors.join('\n'));
// Language switching and custom names must not change the internal device ID.
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的画框'}];shell()});assert.equal(await p.locator('#device option:checked').innerText(),'我的画框');
await p.evaluate(()=>{state.devices=[];shell();login()});
if(out&&lang==='zh'&&theme==='dark'&&[1440,390].includes(width))await p.screenshot({path:path.join(out,`login-${width}.png`),fullPage:true});
console.log('PASS',width,lang,theme);count++;await p.close();}
console.log('TOTAL',count,'profiles');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+23
View File
@@ -0,0 +1,23 @@
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
const root=path.join(__dirname,'../pigway_frame_flow/web');
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(r.request().method()==='PUT'){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.evaluate(()=>photoSourceForm());
assert(!(await p.locator('#source-kind').isVisible()));assert(!(await p.locator('#source-id').isVisible()));assert(!(await p.locator('#source-synology-previews').isVisible()));
assert.equal(await p.locator('#source-kind').inputValue(),'synology-photos');assert.match(await p.locator('#source-id').inputValue(),/^photos-[a-z0-9-]+$/);
const text=await p.locator('#modal').innerText();assert(!/\/photos|本地目录|local.folder|mounted/i.test(text));
assert(await p.locator('#source-people-space').isVisible());await p.locator('#source-people-load').click();await p.locator('[data-person-id], #source-people-panel input[type=checkbox]').last().waitFor();
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await p.locator('#source-name').fill('Family');
await p.locator('#photo-source-form button[type=submit]').click();await p.waitForTimeout(100);
assert(saved);assert.equal(saved.kind,'synology-photos');assert.equal(saved.synology_previews,false);
await p.evaluate(()=>photoSourceForm({id:'family-photos',name:'Family',kind:'synology-photos',location:'.',people_filter:true,person_ids:[1],people_space:'personal',person_match:'all'}));assert.equal(await p.locator('#source-id').inputValue(),'family-photos');assert.equal(await p.locator('#source-people-space').inputValue(),'personal');assert.equal(await p.locator('#source-person-match').inputValue(),'all');assert(await p.locator('#source-people-filter').isChecked());if(lang==='zh')await p.screenshot({path:'/tmp/frame-source-'+width+'.png',fullPage:true});assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});