Merge "Add support for uploading and downloading." am: 316ce8acca am: 95f76c2367

Original change: https://android-review.googlesource.com/c/platform/development/+/1728650

Change-Id: I08248727a78ee8d74db320993247b917e1879772
This commit is contained in:
Treehugger Robot
2021-06-08 20:15:50 +00:00
committed by Automerger Merge Worker
12 changed files with 414 additions and 166 deletions

View File

@@ -15,6 +15,13 @@ In this case we use `lunch 17` as an example (aosp-x86_64-cf), you can choose wh
Then, in this directory, please use `npm build` to install the dependencies.
Create a `target` directory to store the target files and a `output` directory
to store the output files:
```
mkdir target
mkdir output
```
Finally, run the python http-server and vue.js server:
```
python3 web_server.py &

View File

@@ -0,0 +1,86 @@
import subprocess
import os
import json
import pipes
from threading import Lock
import logging
class ProcessesManagement:
def __init__(self):
self.__container = {}
self.__lock = Lock()
def set(self, name, value):
with self.__lock:
self.__container[name] = value
def get(self, name):
with self.__lock:
return self.__container[name]
def get_keys(self):
with self.__lock:
return self.__container.keys()
def get_status_by_ID(self, id=0, details=False):
status = {}
if not id in self.get_keys():
return '{}'
else:
status['id'] = id
if self.get(id).poll() == None:
status['status'] = 'Running'
elif self.get(id).poll() == 0:
status['status'] = 'Finished'
status['path'] = os.path.join('output', str(id) + '.zip')
else:
status['status'] = 'Error'
try:
if details:
with open(os.path.join('output', 'stdout.' + str(id)), 'r') as fout:
status['stdout'] = fout.read()
with open(os.path.join('output', 'stderr.' + str(id)), 'r') as ferr:
status['stderr'] = ferr.read()
except FileNotFoundError:
status['stdout'] = 'NO STD OUTPUT IS FOUND'
status['stderr'] = 'NO STD OUTPUT IS FOUND'
return status
def get_status(self):
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):
command = ['ota_from_target_files']
# Check essential configuration is properly set
if not os.path.isfile('target/' + args['target']):
raise FileNotFoundError
if not args['output']:
raise SyntaxError
if args['verbose']:
command.append('-v')
command.append('-k')
command.append(
'../../../build/make/target/product/security/testkey')
if args['incremental']:
if not os.path.isfile('target/' + args['incremental']):
raise FileNotFoundError
command.append('-i')
command.append('target/' + args['incremental'])
command.append('target/' + args['target'])
command.append(args['output'])
stderr_pipes = pipes.Template()
stdout_pipes = pipes.Template()
ferr = stderr_pipes.open(os.path.join(
'output', 'stderr.'+str(id)), 'w')
fout = stdout_pipes.open(os.path.join(
'output', 'stdout.'+str(id)), 'w')
self.set(id, subprocess.Popen(
command, stderr=ferr, stdout=fout))
logging.info(
'Starting generating OTA package with id {}: \n {}'
.format(id, command))

View File

@@ -12,6 +12,7 @@
"eslint-config-airbnb-base": "^14.2.1",
"vue": "^3.0.0-0",
"vue-router": "^4.0.0-0",
"vue-uuid": "^2.0.2",
"vuex": "^4.0.0-0"
},
"devDependencies": {
@@ -1479,6 +1480,11 @@
"source-map": "^0.6.1"
}
},
"node_modules/@types/uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ=="
},
"node_modules/@types/webpack": {
"version": "4.41.21",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz",
@@ -13339,6 +13345,23 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue-uuid": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vue-uuid/-/vue-uuid-2.0.2.tgz",
"integrity": "sha512-PRf1CHg3uKi77bVRyAuW2u/T2PO9LxMr7cw9t9rNdpZTkNDyw1Fx6eJVL+8JOtM9VxxPkoZ/rwhXJ5l+X5AYzQ==",
"dependencies": {
"@types/uuid": "^8.0.0",
"uuid": "^8.1.0"
}
},
"node_modules/vue-uuid/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vuex": {
"version": "4.0.0-beta.4",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-4.0.0-beta.4.tgz",
@@ -15792,6 +15815,11 @@
"source-map": "^0.6.1"
}
},
"@types/uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ=="
},
"@types/webpack": {
"version": "4.41.21",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz",
@@ -25710,6 +25738,22 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"vue-uuid": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vue-uuid/-/vue-uuid-2.0.2.tgz",
"integrity": "sha512-PRf1CHg3uKi77bVRyAuW2u/T2PO9LxMr7cw9t9rNdpZTkNDyw1Fx6eJVL+8JOtM9VxxPkoZ/rwhXJ5l+X5AYzQ==",
"requires": {
"@types/uuid": "^8.0.0",
"uuid": "^8.1.0"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
"vuex": {
"version": "4.0.0-beta.4",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-4.0.0-beta.4.tgz",

View File

@@ -12,6 +12,7 @@
"eslint-config-airbnb-base": "^14.2.1",
"vue": "^3.0.0-0",
"vue-router": "^4.0.0-0",
"vue-uuid": "^2.0.2",
"vuex": "^4.0.0-0"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
<template>
<label class="file-select">
<div class="select-button">
<span v-if="value">Selected File: {{ value.name }}</span>
<span v-if="label">{{ label }}</span>
<span v-else>Select File</span>
</div>
<input
@@ -14,12 +14,14 @@
<script>
export default {
props: {
value: File
label: {
type: String,
default: ''
}
},
methods: {
handleFileChange(e) {
this.$emit('input', e.target.files[0])
this.$emit('file-select', e.target.files)
}
}
}

View File

@@ -1,5 +1,6 @@
<template>
<label> {{ label }} </label>
<br>
<input
v-bind="$attrs"
:placeholder="label"

View File

@@ -1,8 +1,12 @@
<template>
<router-link
:to="{ name: 'JobDetails', params: {id: job.id} }"
>
<div class="job-display">
<span>Status of Job.{{ job.id }}</span>
<h4>{{ job.status }}</h4>
</div>
</router-link>
</template>
<script>

View File

@@ -0,0 +1,53 @@
<template>
<div>
<BaseFile
label="Upload a target file"
@file-select="UploadHandler"
/>
<br>
<progress
v-if="uploadPercentage > 0"
max="100"
:value.prop="uploadPercentage"
/>
</div>
</template>
<script>
import BaseFile from '@/components/BaseFile.vue'
import ApiService from '../services/ApiService.js'
export default {
components: {
BaseFile,
},
data() {
return {
files: '',
uploadPercentage: 0,
}
},
methods: {
async UploadHandler(files) {
this.file = files[0]
console.log(this.file.name)
try {
let response = await ApiService.uploadTarget(this.file, (event) => {
this.uploadPercentage = Math.round((100 * event.loaded) / event.total)
})
console.log(response)
this.$emit('file-uploaded')
} catch (err) {
console.log(err)
}
},
},
}
</script>
<style scoped>
progress {
width: 100%;
height: 40px;
}
</style>

View File

@@ -13,11 +13,25 @@ export default {
getJobs() {
return apiClient.get("/check")
},
getJobById(id) {
return apiClient.get("/check/" + id)
},
getFileList(path) {
return apiClient.get("/file" + path)
},
uploadTarget(file, onUploadProgress) {
let formData = new FormData()
formData.append('file', file)
return apiClient.post("/file/" + file.name,
formData,
{
onUploadProgress
})
},
async postInput(input, id) {
try {
const response = await apiClient.post(
'/run/' + id, input)
console.log('Response:', response)
return response
} catch (err) {
console.log('err:', err)

View File

@@ -1,23 +1,23 @@
<template>
<div v-if="job">
<h3> Job. {{ job.id }} {{ job.status }}</h3>
<h3>Job. {{ job.id }} {{ job.status }}</h3>
<div>
<h4> STDERR </h4>
<h4>STDERR</h4>
<div class="stderr">
{{ job.stderr }}
<p ref="stderr_bottom" />
</div>
<h4> STDOUT </h4>
<h4>STDOUT</h4>
<div class="stdout">
{{ job.stdout }}
<p ref="stdout_bottom" />
</div>
</div>
<br>
<a
v-if="job.status=='Finished'"
v-if="job.status == 'Finished'"
:href="download"
>
Download
</a>
> Download </a>
</div>
</template>
@@ -32,8 +32,8 @@ export default {
},
computed: {
download() {
return "http://localhost:8000/download/" + this.job.path
}
return 'http://localhost:8000/download/' + this.job.path
},
},
created() {
this.updateStatus()
@@ -44,20 +44,22 @@ export default {
try {
let response = await ApiService.getJobById(this.id)
this.job = response.data
await this.$refs.stdout_bottom.scrollIntoView({ behavior: 'smooth' })
await this.$refs.stderr_bottom.scrollIntoView({ behavior: 'smooth' })
} catch (err) {
console.log(err)
}
catch (err) {
console.log(error)
}
if (this.job.status=='Running') {
if (this.job.status == 'Running') {
setTimeout(this.updateStatus, 1000)
}
}
}
},
},
}
</script>
<style scoped>
.stderr, .stdout {
.stderr,
.stdout {
overflow: scroll;
height: 200px;
}

View File

@@ -1,57 +1,63 @@
<template>
<div>
<form @submit.prevent="sendForm">
<BaseInput
<UploadFile @file-uploaded="fetchTargetList" />
<br>
<BaseSelect
v-if="input.incrementalStatus"
v-model="input.incremental"
:disabled="!input.incrementalStatus"
:label="'Source Package Path'"
type="text"
label="Select the source file"
:options="targetList"
/>
<BaseInput
<BaseSelect
v-model="input.target"
label="Target File path"
type="text"
label="Select the target file"
:options="targetList"
/>
<button
type="button"
@click="fetchTargetList"
>
Update File List
</button>
<div>
<BaseCheckbox
v-model="input.verbose"
:label="'Verbose'"
/>
&emsp;
<BaseCheckbox
v-model="input.incrementalStatus"
:label="'Incremental'"
/>
</div>
<br>
<BaseInput
v-model="input.output"
label="Output File path"
type="text"
v-model="input.extra"
:label="'Extra Configurations'"
/>
<br>
<button type="submit">
Submit
</button>
</form>
<pre> {{ input }} </pre>
<h3> Response from the server </h3>
<div> {{ response_message }}</div>
</div>
</template>
<script>
import BaseInput from '@/components/BaseInput.vue'
import BaseCheckbox from '@/components/BaseCheckbox.vue'
import BaseSelect from '@/components/BaseSelect.vue'
import ApiService from '../services/ApiService.js'
import UploadFile from '@/components/UploadFile.vue'
import { uuid } from 'vue-uuid'
export default {
components: {
BaseInput,
BaseCheckbox
BaseCheckbox,
UploadFile,
BaseSelect,
},
data() {
return {
@@ -59,31 +65,61 @@ export default {
input: {
verbose: false,
target: '',
output: '',
output: 'output/',
incremental: '',
incrementalStatus: false
incrementalStatus: false,
extra: '',
},
inputs: [],
response_message : ''
response_message: '',
targetList: [],
}
},
methods:{
computed: {
updateOutput() {
return 'output/' + String(this.id) + '.zip'
},
},
created() {
this.fetchTargetList()
this.updateUUID()
},
methods: {
sendForm(e) {
// console.log(this.input)
ApiService.postInput(this.input, this.id)
.then(Response => {
.then((Response) => {
this.response_message = Response.data
alert(this.response_message)
})
.catch(err => {
.catch((err) => {
this.response_message = 'Error! ' + err
})
this.input = {
verbose: false,
target: '',
output: '',
output: 'output/',
incremental: '',
incrementalStatus: false
incrementalStatus: false,
extra: '',
}
this.updateUUID()
},
async fetchTargetList() {
try {
let response = await ApiService.getFileList('/target')
this.targetList = response.data
} catch (err) {
console.log('Fetch Error', err)
}
},
updateUUID() {
this.id = uuid.v1()
this.input.output += String(this.id) + '.zip'
},
},
}
</script>
<style scoped>
</style>

View File

@@ -1,20 +1,27 @@
"""
A simple local HTTP server for Android OTA package generation.
Based on OTA_from_target_files.
A local HTTP server for Android OTA package generation.
Based on OTA_from_target_files.py
Usage::
python ./web_server.py [<port>]
API::
GET /check : check the status of all jobs
[TODO] GET /check/id : check the status of the job with <id>
POST /run/id : submit a job with <id>,
GET /check/<id> : check the status of the job with <id>
GET /file : fetch the target file list
GET /download/<id> : download the ota package with <id>
POST /run/<id> : submit a job with <id>,
arguments set in a json uploaded together
[TODO] POST /cancel/id : cancel a job with <id>
POST /file/<filename> : upload a target file
[TODO] POST /cancel/<id> : cancel a job with <id>
Other GET request will be redirected to the static request under 'dist' directory
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from threading import Lock
from ota_interface import ProcessesManagement
import logging
import json
import pipes
@@ -25,101 +32,70 @@ import os
LOCAL_ADDRESS = '0.0.0.0'
class ThreadSafeContainer:
def __init__(self):
self.__container = {}
self.__lock = Lock()
def set(self, name, value):
with self.__lock:
self.__container[name] = value
def get(self, name):
with self.__lock:
return self.__container[name]
def get_keys(self):
with self.__lock:
return self.__container.keys()
class RequestHandler(BaseHTTPRequestHandler):
def get_status(self):
statusList = []
for id in PROCESSES.get_keys():
status = {}
status['id'] = id
if PROCESSES.get(id).poll() == None:
status['status'] = 'Running'
elif PROCESSES.get(id).poll() == 0:
status['status'] = 'Finished'
else:
status['status'] = 'Error'
statusList.append(json.dumps(status))
return '['+','.join(statusList)+']'
def ota_generate(self, args, id=0):
command = ['ota_from_target_files']
# Check essential configuration is properly set
if not os.path.isfile(args['target']):
raise FileNotFoundError
if not args['output']:
raise SyntaxError
if args['verbose']:
command.append('-v')
command.append('-k')
command.append(
'../../../build/make/target/product/security/testkey')
if args['incremental']:
command.append('-i')
command.append(args['incremental'])
command.append(args['target'])
command.append(args['output'])
stderr_pipes = pipes.Template()
stdout_pipes = pipes.Template()
ferr = stderr_pipes.open('stderr', 'w')
fout = stdout_pipes.open('stdout', 'w')
PROCESSES.set(id, subprocess.Popen(
command, stderr=ferr, stdout=fout))
logging.info(
'Starting generating OTA package with id {}: \n {}'
.format(id, command))
def _set_response(self, type='text/html'):
self.send_response(200)
self.send_header('Content-type', type)
class CORSSimpleHTTPHandler(SimpleHTTPRequestHandler):
def end_headers(self):
try:
origin_address, _ = cgi.parse_header(self.headers['Origin'])
self.send_header('Access-Control-Allow-Credentials', 'true')
self.send_header('Access-Control-Allow-Origin', origin_address)
except TypeError:
pass
super().end_headers()
class RequestHandler(CORSSimpleHTTPHandler):
def _set_response(self, code=200, type='text/html'):
self.send_response(code)
self.send_header('Content-type', type)
self.end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
if str(self.path) == '/check':
status = self.get_status()
self._set_response('application/json')
if self.path.startswith('/check'):
if self.path == '/check' or self.path == '/check/':
status = jobs.get_status()
self._set_response(type='application/json')
self.wfile.write(
status.encode()
json.dumps(status).encode()
)
else:
self.send_error(404)
return
id = self.path[7:]
status = jobs.get_status_by_ID(id=id, details=True)
self._set_response(type='application/json')
self.wfile.write(
json.dumps(status).encode()
)
logging.info(
"GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
str(self.path), str(self.headers), status
)
return
elif self.path.startswith('/file'):
file_list = jobs.get_list(self.path[6:])
self._set_response(type='application/json')
self.wfile.write(
json.dumps(file_list).encode()
)
logging.info(
"GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
str(self.path), str(self.headers), file_list
)
return
elif self.path.startswith('/download'):
self.path = self.path[10:]
return CORSSimpleHTTPHandler.do_GET(self)
else:
self.path = '/dist' + self.path
return CORSSimpleHTTPHandler.do_GET(self)
def do_POST(self):
if self.path.startswith('/run'):
content_type, _ = cgi.parse_header(self.headers['content-type'])
if content_type != 'application/json':
self.send_response(400)
@@ -127,21 +103,43 @@ class RequestHandler(BaseHTTPRequestHandler):
return
content_length = int(self.headers['Content-Length'])
post_data = json.loads(self.rfile.read(content_length))
if str(self.path)[:4] == '/run':
try:
self.ota_generate(post_data, id=str(self.path[5:]))
self._set_response()
jobs.ota_generate(post_data, id=str(self.path[5:]))
self._set_response(code=201)
self.wfile.write(
"ota generator start running".encode('utf-8'))
except SyntaxError:
self.send_error(400)
else:
self.send_error(400)
logging.info(
"POST request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n",
str(self.path), str(self.headers),
json.dumps(post_data)
)
elif self.path.startswith('/file'):
file_name = os.path.join('target', self.path[6:])
file_length = int(self.headers['Content-Length'])
with open(file_name, 'wb') as output_file:
# Unwrap the uploaded file first (due to the usage of FormData)
# The wrapper has a boundary line at the top and bottom
# and some file information in the beginning
# There are a file content line, a file name line, and an empty line
# The boundary line in the bottom is 4 bytes longer than the top one
# Please refer to the following links for more details:
# https://stackoverflow.com/questions/8659808/how-does-http-file-upload-work
# https://datatracker.ietf.org/doc/html/rfc1867
upper_boundary = self.rfile.readline()
file_length -= len(upper_boundary) * 2 + 4
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))
self._set_response(code=201)
self.wfile.write(
"File received, saved into {}".format(
file_name).encode('utf-8')
)
else:
self.send_error(400)
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
@@ -166,7 +164,7 @@ def run_server(SeverClass=ThreadedHTTPServer, HandlerClass=RequestHandler, port=
if __name__ == '__main__':
from sys import argv
print(argv)
PROCESSES = ThreadSafeContainer()
jobs = ProcessesManagement()
if len(argv) == 2:
run_server(port=int(argv[1]))
else: