Merge "Add support for partial update and target library."

This commit is contained in:
Treehugger Robot
2021-06-15 16:21:50 +00:00
committed by Gerrit Code Review
9 changed files with 407 additions and 51 deletions

View File

@@ -25,3 +25,4 @@ pnpm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.db

View File

@@ -1,4 +1,5 @@
module.exports = { module.exports = {
singleQuote: true, singleQuote: true,
semi: false semi: false,
useTabs: false
} }

View File

@@ -49,14 +49,10 @@ class ProcessesManagement:
def get_status(self): def get_status(self):
return [self.get_status_by_ID(id=id) for id in self.get_keys()] return [self.get_status_by_ID(id=id) for id in self.get_keys()]
def get_list(self, dir):
files = os.listdir(dir)
return files
def ota_generate(self, args, id=0): def ota_generate(self, args, id=0):
command = ['ota_from_target_files'] command = ['ota_from_target_files']
# Check essential configuration is properly set # Check essential configuration is properly set
if not os.path.isfile('target/' + args['target']): if not os.path.isfile(args['target']):
raise FileNotFoundError raise FileNotFoundError
if not args['output']: if not args['output']:
raise SyntaxError raise SyntaxError
@@ -65,14 +61,17 @@ class ProcessesManagement:
command.append('-k') command.append('-k')
command.append( command.append(
'../../../build/make/target/product/security/testkey') '../../../build/make/target/product/security/testkey')
if args['incremental']: if args['isIncremental']:
if not os.path.isfile('target/' + args['incremental']): if not os.path.isfile(args['incremental']):
raise FileNotFoundError raise FileNotFoundError
command.append('-i') command.append('-i')
command.append('target/' + args['incremental']) command.append(args['incremental'])
command.append('target/' + args['target']) if args['isPartial']:
command.append('--partial')
command.append(args['partial'])
command.append(args['target'])
command.append(args['output']) command.append(args['output'])
# Start a subprocess and collect the output
stderr_pipes = pipes.Template() stderr_pipes = pipes.Template()
stdout_pipes = pipes.Template() stdout_pipes = pipes.Template()
ferr = stderr_pipes.open(os.path.join( ferr = stderr_pipes.open(os.path.join(

View File

@@ -3,6 +3,7 @@
type="checkbox" type="checkbox"
:checked="modelValue" :checked="modelValue"
class="field" class="field"
v-bind="$attrs"
@change="$emit('update:modelValue', $event.target.checked)" @change="$emit('update:modelValue', $event.target.checked)"
> >
<label v-if="label"> {{ label }} </label> <label v-if="label"> {{ label }} </label>

View File

@@ -0,0 +1,39 @@
<template>
<label v-if="label"> {{ label }} </label>
<select
:value="modelValue"
class="field"
v-bind="$attrs"
@change="$emit('update:modelValue', $event.target.value)"
>
<option
v-for="option in options"
:key="option.file_name"
:value="option.path"
:selected="option.path === modelValue"
>
{{ option.file_name }}
</option>
</select>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
required: true
}
}
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<ul v-bind="$attrs">
<li
v-for="label in labels"
:key="label"
>
<input
type="checkbox"
:value="label"
:checked="modelValue.get(label)"
@change="updateSelected($event.target.value)"
>
<label v-if="label"> {{ label }} </label>
</li>
</ul>
</template>
<script>
export default {
props: {
labels: {
type: Array,
default: new Array(),
},
modelValue: {
type: Map,
default: new Map(),
},
},
methods: {
updateSelected(newSelect) {
this.modelValue.set(newSelect, !this.modelValue.get(newSelect))
this.$emit('update:modelValue', this.modelValue)
},
},
}
</script>

View File

@@ -3,16 +3,16 @@
<form @submit.prevent="sendForm"> <form @submit.prevent="sendForm">
<UploadFile @file-uploaded="fetchTargetList" /> <UploadFile @file-uploaded="fetchTargetList" />
<br> <br>
<BaseSelect <FileSelect
v-if="input.incrementalStatus" v-if="input.isIncremental"
v-model="input.incremental" v-model="input.incremental"
label="Select the source file" label="Select the source file"
:options="targetList" :options="targetDetails"
/> />
<BaseSelect <FileSelect
v-model="input.target" v-model="input.target"
label="Select the target file" label="Select the target file"
:options="targetList" :options="targetDetails"
/> />
<button <button
type="button" type="button"
@@ -24,13 +24,26 @@
<BaseCheckbox <BaseCheckbox
v-model="input.verbose" v-model="input.verbose"
:label="'Verbose'" :label="'Verbose'"
/> /> &emsp;
&emsp;
<BaseCheckbox <BaseCheckbox
v-model="input.incrementalStatus" v-model="input.isIncremental"
:label="'Incremental'" :label="'Incremental'"
/> />
</div> </div>
<div>
<BaseCheckbox
v-model="input.isPartial"
:label="'Partial'"
/>
<PartialCheckbox
v-if="input.isPartial"
v-model="partitionInclude"
:labels="updatePartitions"
/>
<div v-if="input.isPartial">
Partial list: {{ partitionList }}
</div>
</div>
<br> <br>
<BaseInput <BaseInput
v-model="input.extra" v-model="input.extra"
@@ -42,14 +55,53 @@
</button> </button>
</form> </form>
</div> </div>
<div>
<ul>
<h4>Build Library</h4>
<strong>
Careful: Use a same filename will overwrite the original build.
</strong>
<br>
<button @click="updateBuildLib">
Refresh the build Library (use with cautions)
</button>
<li
v-for="targetDetail in targetDetails"
:key="targetDetail.file_name"
>
<div>
<h5>Build File Name: {{ targetDetail.file_name }}</h5>
Uploaded time: {{ formDate(targetDetail.time) }}
<br>
Build ID: {{ targetDetail.build_id }}
<br>
Build Version: {{ targetDetail.build_version }}
<br>
Build Flavor: {{ targetDetail.build_flavor }}
<br>
<button
:disabled="!input.isIncremental"
@click="selectIncremental(targetDetail.path)"
>
Select as Incremental File
</button>
&emsp;
<button @click="selectTarget(targetDetail.path)">
Select as Target File
</button>
</div>
</li>
</ul>
</div>
</template> </template>
<script> <script>
import BaseInput from '@/components/BaseInput.vue' import BaseInput from '@/components/BaseInput.vue'
import BaseCheckbox from '@/components/BaseCheckbox.vue' import BaseCheckbox from '@/components/BaseCheckbox.vue'
import BaseSelect from '@/components/BaseSelect.vue' import FileSelect from '@/components/FileSelect.vue'
import ApiService from '../services/ApiService.js' import ApiService from '../services/ApiService.js'
import UploadFile from '@/components/UploadFile.vue' import UploadFile from '@/components/UploadFile.vue'
import PartialCheckbox from '@/components/PartialCheckbox.vue'
import { uuid } from 'vue-uuid' import { uuid } from 'vue-uuid'
export default { export default {
@@ -57,58 +109,76 @@ export default {
BaseInput, BaseInput,
BaseCheckbox, BaseCheckbox,
UploadFile, UploadFile,
BaseSelect, FileSelect,
PartialCheckbox,
}, },
data() { data() {
return { return {
id: 0, id: 0,
input: { input: {},
verbose: false,
target: '',
output: 'output/',
incremental: '',
incrementalStatus: false,
extra: '',
},
inputs: [], inputs: [],
response_message: '', response_message: '',
targetList: [], targetDetails: [],
partitionInclude: new Map(),
} }
}, },
computed: { computed: {
updateOutput() { updatePartitions() {
return 'output/' + String(this.id) + '.zip' let target = this.targetDetails.filter(
(d) => d.path === this.input.target
)
return target[0].partitions
},
partitionList() {
let list = ''
for (let [key, value] of this.partitionInclude) {
if (value) {
list += key + ' '
}
}
return list
},
},
watch: {
partitionList: {
handler: function () {
this.input.partial = this.partitionList
},
}, },
}, },
created() { created() {
this.resetInput()
this.fetchTargetList() this.fetchTargetList()
this.updateUUID() this.updateUUID()
}, },
methods: { methods: {
sendForm(e) { resetInput() {
// console.log(this.input)
ApiService.postInput(this.input, this.id)
.then((Response) => {
this.response_message = Response.data
alert(this.response_message)
})
.catch((err) => {
this.response_message = 'Error! ' + err
})
this.input = { this.input = {
verbose: false, verbose: false,
target: '', target: '',
output: 'output/', output: 'output/',
incremental: '', incremental: '',
incrementalStatus: false, isIncremental: false,
partial: '',
isPartial: false,
extra: '', extra: '',
} }
},
async sendForm(e) {
try {
let response = await ApiService.postInput(this.input, this.id)
this.response_message = response.data
alert(this.response_message)
} catch (err) {
console.log(err)
}
this.resetInput()
this.updateUUID() this.updateUUID()
}, },
async fetchTargetList() { async fetchTargetList() {
try { try {
let response = await ApiService.getFileList('/target') let response = await ApiService.getFileList('')
this.targetList = response.data this.targetDetails = response.data
} catch (err) { } catch (err) {
console.log('Fetch Error', err) console.log('Fetch Error', err)
} }
@@ -117,6 +187,36 @@ export default {
this.id = uuid.v1() this.id = uuid.v1()
this.input.output += String(this.id) + '.zip' this.input.output += String(this.id) + '.zip'
}, },
formDate(unixTime) {
let formTime = new Date(unixTime * 1000)
let date =
formTime.getFullYear() +
'-' +
(formTime.getMonth() + 1) +
'-' +
formTime.getDate()
let time =
formTime.getHours() +
':' +
formTime.getMinutes() +
':' +
formTime.getSeconds()
return date + ' ' + time
},
selectTarget(path) {
this.input.target = path
},
selectIncremental(path) {
this.input.incremental = path
},
async updateBuildLib() {
try {
let response = await ApiService.getFileList('/target')
this.targetDetails = response.data
} catch (err) {
console.log('Fetch Error', err)
}
},
}, },
} }
</script> </script>

158
tools/otagui/target_lib.py Normal file
View File

@@ -0,0 +1,158 @@
from dataclasses import dataclass, asdict, field
import sqlite3
import time
import logging
import os
import zipfile
import re
import json
@dataclass
class BuildInfo:
"""
A class for Android build information.
"""
file_name: str
path: str
time: int
build_id: str = ''
build_version: str = ''
build_flavor: str = ''
partitions: list[str] = field(default_factory=list)
def analyse_buildprop(self):
"""
Analyse the build's version info and partitions included
Then write them into the build_info
"""
def extract_info(pattern, lines):
# Try to match a regex in a list of string
line = list(filter(pattern.search, lines))[0]
if line:
return pattern.search(line).group(0)
else:
return ''
build = zipfile.ZipFile(self.path)
try:
with build.open('SYSTEM/build.prop', 'r') as build_prop:
raw_info = build_prop.readlines()
pattern_id = re.compile(b'(?<=ro\.build\.id\=).+')
pattern_version = re.compile(
b'(?<=ro\.build\.version\.incremental\=).+')
pattern_flavor = re.compile(b'(?<=ro\.build\.flavor\=).+')
self.build_id = extract_info(
pattern_id, raw_info).decode('utf-8')
self.build_version = extract_info(
pattern_version, raw_info).decode('utf-8')
self.build_flavor = extract_info(
pattern_flavor, raw_info).decode('utf-8')
except KeyError:
pass
try:
with build.open('META/ab_partitions.txt', 'r') as partition_info:
raw_info = partition_info.readlines()
for line in raw_info:
self.partitions.append(line.decode('utf-8').rstrip())
except KeyError:
pass
def to_sql_form_dict(self):
sql_form_dict = asdict(self)
sql_form_dict['partitions'] = ','.join(sql_form_dict['partitions'])
return sql_form_dict
def to_dict(self):
return asdict(self)
class TargetLib:
def __init__(self, path='ota_database.db'):
"""
Create a build table if not existing
"""
self.path = path
with sqlite3.connect(self.path) as connect:
cursor = connect.cursor()
cursor.execute("""
CREATE TABLE if not exists Builds (
FileName TEXT,
UploadTime INTEGER,
Path TEXT,
BuildID TEXT,
BuildVersion TEXT,
BuildFlavor TEXT,
Partitions TEXT
)
""")
def new_build(self, filename, path):
"""
Insert a new build into the database
Args:
filename: the name of the file
path: the relative path of the file
"""
build_info = BuildInfo(filename, path, int(time.time()))
build_info.analyse_buildprop()
with sqlite3.connect(self.path) as connect:
cursor = connect.cursor()
cursor.execute("""
SELECT * FROM Builds WHERE FileName=:file_name and Path=:path
""", build_info.to_sql_form_dict())
if cursor.fetchall():
cursor.execute("""
DELETE FROM Builds WHERE FileName=:file_name and Path=:path
""", build_info.to_sql_form_dict())
cursor.execute("""
INSERT INTO Builds (FileName, UploadTime, Path, BuildID, BuildVersion, BuildFlavor, Partitions)
VALUES (:file_name, :time, :path, :build_id, :build_version, :build_flavor, :partitions)
""", build_info.to_sql_form_dict())
def new_build_from_dir(self, path):
"""
Update the database using files under a directory
Args:
path: a directory
"""
if os.path.isdir(path):
builds_name = os.listdir(path)
for build_name in builds_name:
self.new_build(build_name, os.path.join(path, build_name))
elif os.path.isfile(path):
self.new_build(os.path.split(path)[-1], path)
return self.get_builds()
def sql_to_buildinfo(self, row):
build_info = BuildInfo(*row[:6], row[6].split(','))
return build_info
def get_builds(self):
"""
Get a list of builds in the database
Return:
A list of build_info, each of which is an object:
(FileName, UploadTime, Path, Build ID, Build Version, Build Flavor, Partitions)
"""
with sqlite3.connect(self.path) as connect:
cursor = connect.cursor()
cursor.execute("""
SELECT FileName, Path, UploadTime, BuildID, BuildVersion, BuildFlavor, Partitions
FROM Builds""")
return list(map(self.sql_to_buildinfo, cursor.fetchall()))
def get_builds_by_path(self, path):
"""
Get a build in the database by its path
Return:
A build_info, which is an object:
(FileName, UploadTime, Path, Build ID, Build Version, Build Flavor, Partitions)
"""
with sqlite3.connect(self.path) as connect:
cursor = connect.cursor()
cursor.execute("""
SELECT FileName, Path, UploadTime, BuildID, BuildVersion, BuildFlavor, Partitions
WHERE Path==(?)
""", (path, ))
return self.sql_to_buildinfo(cursor.fetchone())

View File

@@ -9,12 +9,17 @@ API::
GET /check : check the status of all jobs GET /check : check the status of all jobs
GET /check/<id> : check the status of the job with <id> GET /check/<id> : check the status of the job with <id>
GET /file : fetch the target file list GET /file : fetch the target file list
GET /file/<path> : Add build file(s) in <path>, and return the target file list
GET /download/<id> : download the ota package with <id> GET /download/<id> : download the ota package with <id>
POST /run/<id> : submit a job with <id>, POST /run/<id> : submit a job with <id>,
arguments set in a json uploaded together arguments set in a json uploaded together
POST /file/<filename> : upload a target file POST /file/<filename> : upload a target file
[TODO] POST /cancel/<id> : cancel a job with <id> [TODO] POST /cancel/<id> : cancel a job with <id>
TODO:
- Avoid unintentionally path leakage
- Avoid overwriting build when uploading build with same file name
Other GET request will be redirected to the static request under 'dist' directory Other GET request will be redirected to the static request under 'dist' directory
""" """
@@ -22,12 +27,14 @@ from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, HTTPSe
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from threading import Lock from threading import Lock
from ota_interface import ProcessesManagement from ota_interface import ProcessesManagement
from target_lib import TargetLib
import logging import logging
import json import json
import pipes import pipes
import cgi import cgi
import subprocess import subprocess
import os import os
import sys
LOCAL_ADDRESS = '0.0.0.0' LOCAL_ADDRESS = '0.0.0.0'
@@ -77,10 +84,14 @@ class RequestHandler(CORSSimpleHTTPHandler):
) )
return return
elif self.path.startswith('/file'): elif self.path.startswith('/file'):
file_list = jobs.get_list(self.path[6:]) if self.path == '/file' or self.path == '/file/':
file_list = target_lib.get_builds()
else:
file_list = target_lib.new_build_from_dir(self.path[6:])
builds_info = [build.to_dict() for build in file_list]
self._set_response(type='application/json') self._set_response(type='application/json')
self.wfile.write( self.wfile.write(
json.dumps(file_list).encode() json.dumps(builds_info).encode()
) )
logging.info( logging.info(
"GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n", "GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
@@ -133,6 +144,7 @@ class RequestHandler(CORSSimpleHTTPHandler):
file_length -= len(self.rfile.readline()) file_length -= len(self.rfile.readline())
file_length -= len(self.rfile.readline()) file_length -= len(self.rfile.readline())
output_file.write(self.rfile.read(file_length)) output_file.write(self.rfile.read(file_length))
target_lib.new_build(self.path[6:], file_name)
self._set_response(code=201) self._set_response(code=201)
self.wfile.write( self.wfile.write(
"File received, saved into {}".format( "File received, saved into {}".format(
@@ -164,8 +176,16 @@ def run_server(SeverClass=ThreadedHTTPServer, HandlerClass=RequestHandler, port=
if __name__ == '__main__': if __name__ == '__main__':
from sys import argv from sys import argv
print(argv) print(argv)
if not os.path.isdir('target'):
os.mkdir('target', 755)
if not os.path.isdir('output'):
os.mkdir('output', 755)
target_lib = TargetLib()
jobs = ProcessesManagement() jobs = ProcessesManagement()
if len(argv) == 2: try:
run_server(port=int(argv[1])) if len(argv) == 2:
else: run_server(port=int(argv[1]))
run_server() else:
run_server()
except KeyboardInterrupt:
sys.exit(0)