Fixing merge conflicts, passing py26
This commit is contained in:
commit
d718fd8f6b
4
.gitignore
vendored
4
.gitignore
vendored
@ -10,3 +10,7 @@ devenv/*
|
||||
.coverage
|
||||
coverage.xml
|
||||
.testrepository
|
||||
imagebuild/coreos/build
|
||||
imagebuild/coreos/dist
|
||||
imagebuild/coreos/oem/authorized_keys
|
||||
imagebuild/coreos/UPLOAD
|
||||
|
@ -13,7 +13,7 @@ RUN apt-get update && apt-get -y install \
|
||||
# Install requirements separately, because pip understands a git+https url while setuptools doesn't
|
||||
RUN pip install -r /tmp/teeth-agent/requirements.txt
|
||||
|
||||
# This will succeed because all the dependencies (including pesky teeth_rest) were installed previously
|
||||
# This will succeed because all the dependencies were installed previously
|
||||
RUN pip install /tmp/teeth-agent
|
||||
|
||||
CMD [ "/usr/local/bin/teeth-agent" ]
|
||||
|
4
imagebuild/README.md
Normal file
4
imagebuild/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
teeth-agent images
|
||||
==================
|
||||
|
||||
coreos - Builds a CoreOS Ramdisk and Kernel suitable for running teeth-agent
|
17
imagebuild/coreos/Makefile
Normal file
17
imagebuild/coreos/Makefile
Normal file
@ -0,0 +1,17 @@
|
||||
default: docker coreos
|
||||
|
||||
docker:
|
||||
./docker_build.bash
|
||||
|
||||
coreos:
|
||||
mkdir UPLOAD
|
||||
./coreos-oem-inject.py oem UPLOAD
|
||||
|
||||
clean:
|
||||
rm -rf teeth-agent
|
||||
rm -f oem/container.tar.gz
|
||||
rm -f UPLOAD/coreos_production_pxe_image-oem.cpio.gz
|
||||
rm -f UPLOAD/coreos_production_pxe.vmlinuz
|
||||
|
||||
docker_clean:
|
||||
./docker_clean.bash
|
33
imagebuild/coreos/README.md
Normal file
33
imagebuild/coreos/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# teeth-agent CoreOS Image builder.
|
||||
|
||||
Builds a CoreOS image suitable for running the teeth-agent on a server.
|
||||
|
||||
# Requirements
|
||||
|
||||
Must be run from a linux machine with a working docker installation and python-pip
|
||||
|
||||
Run the following locally or from a virtualenv to install the python requirements
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
# Instructions
|
||||
|
||||
To create a docker repository and embed it into a CoreOS pxe image:
|
||||
```
|
||||
make
|
||||
```
|
||||
|
||||
To just create the docker repository in oem/container.tar.gz:
|
||||
```
|
||||
make docker
|
||||
```
|
||||
|
||||
To embed the oem/ directory into a CoreOS pxe image:
|
||||
|
||||
Note: In order to have the ability to ssh into the created image, you need to
|
||||
pass ssh keys in via the kernel command line for CoreOS, or create
|
||||
oem/authorized_keys with the keys you need added before building the image.
|
||||
```
|
||||
make coreos
|
||||
```
|
246
imagebuild/coreos/coreos-oem-inject.py
Executable file
246
imagebuild/coreos/coreos-oem-inject.py
Executable file
@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
import tempfile
|
||||
import shutil
|
||||
from plumbum import local, cmd
|
||||
|
||||
COREOS_VERSION="dev-channel"
|
||||
|
||||
COREOS_ARCH="amd64-generic"
|
||||
COREOS_BASE_URL="http://storage.core-os.net/coreos/{}/{}".format(COREOS_ARCH, COREOS_VERSION)
|
||||
COREOS_PXE_DIGESTS="coreos_production_pxe_image.cpio.gz.DIGESTS.asc"
|
||||
COREOS_PXE_KERNEL="coreos_production_pxe.vmlinuz"
|
||||
COREOS_PXE_IMAGE="coreos_production_pxe_image.cpio.gz"
|
||||
COREOS_PXE_IMAGE_URL = "{}/{}".format(COREOS_BASE_URL, COREOS_PXE_IMAGE)
|
||||
COREOS_PXE_KERNEL_URL = "{}/{}".format(COREOS_BASE_URL, COREOS_PXE_KERNEL)
|
||||
COREOS_PXE_DIGESTS_URL = "{}/{}".format(COREOS_BASE_URL, COREOS_PXE_DIGESTS)
|
||||
|
||||
|
||||
|
||||
def get_etag(cache_name):
|
||||
etag_file = "{}.etag".format(cache_name)
|
||||
if not os.path.exists(etag_file):
|
||||
return None
|
||||
with open(etag_file, 'rb') as fp:
|
||||
etag = fp.read()
|
||||
etag.strip()
|
||||
return etag
|
||||
|
||||
def save_etag(cache_name, etag):
|
||||
etag_file = "{}.etag".format(cache_name)
|
||||
with open(etag_file, 'w+b') as fp:
|
||||
fp.write(etag)
|
||||
|
||||
def cache_file(cache_name, remote_url):
|
||||
print("{} <- {}".format(cache_name, remote_url))
|
||||
etag = get_etag(cache_name)
|
||||
headers = {}
|
||||
if etag:
|
||||
headers['If-None-Match'] = etag
|
||||
|
||||
start = time.time()
|
||||
r = requests.get(remote_url, headers=headers)
|
||||
|
||||
if r.status_code == 304:
|
||||
print("[etag-match]")
|
||||
return
|
||||
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError('Failed to download {}, got HTTP {} Status Code.'.format(remote_url, r.status_code))
|
||||
|
||||
with open(cache_name, 'w+b') as fp:
|
||||
fp.write(r.content)
|
||||
|
||||
print("{} bytes in {} seconds".format(len(r.content), time.time() - start))
|
||||
save_etag(cache_name, r.headers['etag'])
|
||||
|
||||
def inject_oem(archive, oem_dir, output_file):
|
||||
d = tempfile.mkdtemp(prefix="oem-inject")
|
||||
try:
|
||||
with local.cwd(d):
|
||||
dest_oem_dir = os.path.join(d, 'usr', 'share', 'oem')
|
||||
uz = cmd.gunzip["-c", archive]
|
||||
extract = cmd.cpio["-iv"]
|
||||
chain = uz | extract
|
||||
print chain
|
||||
chain()
|
||||
|
||||
shutil.copytree(oem_dir, dest_oem_dir)
|
||||
|
||||
find = cmd.find['.', '-depth', '-print']
|
||||
cpio = cmd.cpio['-o', '-H', 'newc']
|
||||
gz = cmd.gzip
|
||||
chain = find | cmd.sort | cpio | gz > output_file
|
||||
print chain
|
||||
chain()
|
||||
finally:
|
||||
shutil.rmtree(d)
|
||||
return output_file
|
||||
|
||||
def validate_digests(digests, target, hash_type='sha1'):
|
||||
with local.cwd(os.path.dirname(digests)):
|
||||
gethashes = cmd.grep['-i', '-A1', '^# {} HASH$'.format(hash_type), digests]
|
||||
forthis = cmd.grep[os.path.basename(target)]
|
||||
viasum = local[hash_type + "sum"]['-c', '/dev/stdin']
|
||||
chain = gethashes | forthis | viasum
|
||||
print chain
|
||||
chain()
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: {} [oem-directory-to-inject] [output-directory]".format(os.path.basename(__file__)))
|
||||
return
|
||||
|
||||
oem_dir = os.path.abspath(os.path.expanduser(sys.argv[1]))
|
||||
output_dir = os.path.abspath(os.path.expanduser(sys.argv[2]))
|
||||
|
||||
if not os.path.exists(oem_dir):
|
||||
print("Error: {} doesn't exist.".format(oem_dir))
|
||||
return
|
||||
|
||||
if not os.path.exists(os.path.join(oem_dir, 'run.sh')):
|
||||
print("Error: {} is missing oem.sh".format(oem_dir))
|
||||
return
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
top_cache_dir = os.path.join(os.path.dirname(here), ".image_cache")
|
||||
cache_dir = os.path.join(top_cache_dir, COREOS_ARCH, COREOS_VERSION)
|
||||
|
||||
if not os.path.exists(cache_dir):
|
||||
os.makedirs(cache_dir)
|
||||
|
||||
orig_cpio = os.path.join(cache_dir, COREOS_PXE_IMAGE)
|
||||
digests = os.path.join(cache_dir, COREOS_PXE_DIGESTS)
|
||||
kernel = os.path.join(cache_dir, COREOS_PXE_KERNEL)
|
||||
|
||||
cache_file(digests, COREOS_PXE_DIGESTS_URL)
|
||||
gpg_verify_file(digests)
|
||||
cache_file(kernel, COREOS_PXE_KERNEL_URL)
|
||||
validate_digests(digests, kernel)
|
||||
cache_file(orig_cpio, COREOS_PXE_IMAGE_URL)
|
||||
validate_digests(digests, orig_cpio)
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_kernel = os.path.join(output_dir, os.path.basename(kernel))
|
||||
output_cpio = os.path.join(output_dir, os.path.basename(orig_cpio).replace('.cpio.gz', '-oem.cpio.gz'))
|
||||
inject_oem(orig_cpio, oem_dir, output_cpio)
|
||||
shutil.copy(kernel, output_kernel)
|
||||
|
||||
def gpg_verify_file(ascfile):
|
||||
d = tempfile.mkdtemp(prefix="oem-gpg-validate")
|
||||
try:
|
||||
tmpring = os.path.join(d, 'tmp.gpg')
|
||||
key = os.path.join(d, 'coreos.key')
|
||||
with open(key, 'w+b') as fp:
|
||||
fp.write(gpg_key())
|
||||
|
||||
i = cmd.gpg['--batch',
|
||||
'--no-default-keyring',
|
||||
'--keyring',
|
||||
tmpring,
|
||||
'--import',
|
||||
key]
|
||||
print(i)
|
||||
i()
|
||||
|
||||
r = cmd.gpg['--batch',
|
||||
'--no-default-keyring',
|
||||
'--keyring',
|
||||
tmpring,
|
||||
'--verify',
|
||||
ascfile]
|
||||
print(r)
|
||||
r()
|
||||
|
||||
finally:
|
||||
shutil.rmtree(d)
|
||||
|
||||
def gpg_key():
|
||||
GPG_LONG_ID="50E0885593D2DCB4"
|
||||
GPG_KEY="""-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v2.0.20 (GNU/Linux)
|
||||
|
||||
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
|
||||
BeiJ6tEeDHDzdA==
|
||||
=4Qn0
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
"""
|
||||
return GPG_KEY
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
26
imagebuild/coreos/docker_build.bash
Executable file
26
imagebuild/coreos/docker_build.bash
Executable file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# docker_build.bash - Prepares and outputs a tarball'd docker repository
|
||||
# suitable for injection into a coreos pxe image
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
OUTPUT_FILE="oem/container.tar.gz"
|
||||
|
||||
# If there's already a container.tar.gz, don't overwrite it -- instead, bail
|
||||
if [[ -e "${OUTPUT_FILE}" ]]; then
|
||||
echo "${OUTPUT_FILE} already exists. Will not overwrite. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the docker image
|
||||
cd ../../teeth-agent
|
||||
docker build -t oemdocker .
|
||||
cd -
|
||||
|
||||
# Export the oemdocker repository to a tarball so it can be embedded in CoreOS
|
||||
# TODO: Investigate running a container and using "export" to flatten the
|
||||
# image to shrink the CoreOS fs size. This will also require run.sh to
|
||||
# use docker import instead of docker load as well.
|
||||
docker save oemdocker | gzip > ${OUTPUT_FILE}
|
16
imagebuild/coreos/docker_clean.bash
Executable file
16
imagebuild/coreos/docker_clean.bash
Executable file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Cleans up docker images and containers
|
||||
|
||||
containers=$(docker ps -a -q)
|
||||
images=$(docker images -q)
|
||||
|
||||
# All the docker commands followed by || true because occassionally docker
|
||||
# will fail to remove an image or container, & I want make to keep going anyway
|
||||
if [[ ! -z "$containers" ]]; then
|
||||
docker rm $containers || true
|
||||
fi
|
||||
|
||||
if [[ ! -z "$images" ]]; then
|
||||
docker rmi $images || true
|
||||
fi
|
31
imagebuild/coreos/oem/run.sh
Executable file
31
imagebuild/coreos/oem/run.sh
Executable file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# CoreOS by default only has an OEM partition of 2GB. This isn't large enough
|
||||
# for some images. Remount it with a larger size. Note: When CoreOS changes to
|
||||
# r/w /, instead of remounting here, we'll use rootflags= to set the size.
|
||||
mount -o remount,size=20G /media/state
|
||||
|
||||
cd /usr/share/oem/
|
||||
|
||||
mkdir -pm 0700 /home/core/.ssh
|
||||
|
||||
# TODO: Use proper https://github.com/coreos/init/blob/master/bin/update-ssh-keys script
|
||||
if [[ -e authorized_keys ]]; then
|
||||
cat authorized_keys >> /home/core/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
chown -R core:core /home/core/.ssh/
|
||||
|
||||
# We have to wait until docker is started to proceed
|
||||
# In a perfect world I'd use inotifywait, but that doesn't exist on coreos
|
||||
while [ ! -e /var/run/docker.sock ]; do
|
||||
sleep 1;
|
||||
done
|
||||
|
||||
# TODO: Use docker import (and export the image) to shrink image size
|
||||
docker load < container.tar.gz
|
||||
|
||||
systemctl enable --runtime /usr/share/oem/system/*
|
||||
systemctl start teeth-agent.service
|
6
imagebuild/coreos/oem/system/teeth-agent.service
Normal file
6
imagebuild/coreos/oem/system/teeth-agent.service
Normal file
@ -0,0 +1,6 @@
|
||||
[Service]
|
||||
ExecStart=/usr/bin/docker run -p 9999:9999 -privileged=true -v=/sys:/mnt/sys oemdocker /usr/local/bin/teeth-agent --ipaddr="`ip a | grep '10\.' | sed -e 's/inet \(10\.[0-9\.]\+\).*/\1/'`"
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=oem.target
|
@ -1,7 +1,9 @@
|
||||
Werkzeug==0.9.4
|
||||
requests==2.0.0
|
||||
cherrypy==3.2.4
|
||||
stevedore==0.14
|
||||
-e git+https://github.com/racker/teeth-rest.git@e876c0fddd5ce2f5223ab16936f711b0d57e19c4#egg=teeth_rest
|
||||
structlog
|
||||
ordereddict
|
||||
ordereddict>=1.1
|
||||
wsgiref>=0.1.2
|
||||
pecan>=0.4.5
|
||||
WSME>=0.6
|
||||
six>=1.5.2
|
||||
structlog==0.4.1
|
||||
|
@ -18,15 +18,14 @@ import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cherrypy import wsgiserver
|
||||
import pkg_resources
|
||||
from stevedore import driver
|
||||
import structlog
|
||||
from teeth_rest import encoding
|
||||
from teeth_rest import errors as rest_errors
|
||||
from wsgiref import simple_server
|
||||
|
||||
from teeth_agent import api
|
||||
from teeth_agent.api import app
|
||||
from teeth_agent import base
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import errors
|
||||
from teeth_agent import hardware
|
||||
from teeth_agent import overlord_agent_api
|
||||
@ -39,7 +38,7 @@ class TeethAgentStatus(encoding.Serializable):
|
||||
self.started_at = started_at
|
||||
self.version = version
|
||||
|
||||
def serialize(self, view):
|
||||
def serialize(self):
|
||||
"""Turn the status into a dict."""
|
||||
return utils.get_ordereddict([
|
||||
('mode', self.mode),
|
||||
@ -107,12 +106,13 @@ class TeethAgentHeartbeater(threading.Thread):
|
||||
|
||||
|
||||
class TeethAgent(object):
|
||||
def __init__(self, api_url, listen_address):
|
||||
def __init__(self, api_url, listen_address, ipaddr):
|
||||
self.api_url = api_url
|
||||
self.listen_address = listen_address
|
||||
self.ipaddr = ipaddr
|
||||
self.mode_implementation = None
|
||||
self.version = pkg_resources.get_distribution('teeth-agent').version
|
||||
self.api = api.TeethAgentAPIServer(self)
|
||||
self.api = app.VersionSelectorApplication(self)
|
||||
self.command_results = utils.get_ordereddict()
|
||||
self.heartbeater = TeethAgentHeartbeater(self)
|
||||
self.hardware = hardware.get_manager()
|
||||
@ -180,14 +180,17 @@ class TeethAgent(object):
|
||||
try:
|
||||
result = self.mode_implementation.execute(command_part,
|
||||
**kwargs)
|
||||
except rest_errors.InvalidContentError as e:
|
||||
except errors.InvalidContentError as e:
|
||||
# Any command may raise a InvalidContentError which will be
|
||||
# returned to the caller directly.
|
||||
raise e
|
||||
except Exception as e:
|
||||
# Other errors are considered command execution errors, and are
|
||||
# recorded as an
|
||||
result = base.SyncCommandResult(command_name, kwargs, False, e)
|
||||
result = base.SyncCommandResult(command_name,
|
||||
kwargs,
|
||||
False,
|
||||
unicode(e))
|
||||
|
||||
self.command_results[result.id] = result
|
||||
return result
|
||||
@ -196,13 +199,16 @@ class TeethAgent(object):
|
||||
"""Run the Teeth Agent."""
|
||||
self.started_at = time.time()
|
||||
self.heartbeater.start()
|
||||
server = wsgiserver.CherryPyWSGIServer(self.listen_address, self.api)
|
||||
wsgi = simple_server.make_server(
|
||||
self.listen_address[0],
|
||||
self.listen_address[1],
|
||||
self.api,
|
||||
server_class=simple_server.WSGIServer)
|
||||
|
||||
try:
|
||||
server.start()
|
||||
wsgi.serve_forever()
|
||||
except BaseException as e:
|
||||
self.log.error('shutting down', exception=e)
|
||||
server.stop()
|
||||
|
||||
self.heartbeater.stop()
|
||||
|
||||
@ -217,5 +223,5 @@ def _load_mode_implementation(mode_name):
|
||||
return mgr.driver
|
||||
|
||||
|
||||
def build_agent(api_url, listen_host, listen_port):
|
||||
return TeethAgent(api_url, (listen_host, listen_port))
|
||||
def build_agent(api_url, listen_host, listen_port, ipaddr):
|
||||
return TeethAgent(api_url, (listen_host, listen_port), ipaddr)
|
||||
|
@ -1,99 +0,0 @@
|
||||
"""
|
||||
Copyright 2013 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from teeth_rest import component
|
||||
from teeth_rest import errors
|
||||
from teeth_rest import responses
|
||||
|
||||
|
||||
class AgentCommand(object):
|
||||
def __init__(self, name, params):
|
||||
self.name = name
|
||||
self.params = params
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, obj):
|
||||
for field in ['name', 'params']:
|
||||
if field not in obj:
|
||||
msg = 'Missing command \'{0}\' field.'.format(field)
|
||||
raise errors.InvalidContentError(msg)
|
||||
|
||||
if type(obj['params']) != dict:
|
||||
raise errors.InvalidContentError(
|
||||
'Command params must be a dictionary.')
|
||||
|
||||
return cls(obj['name'], obj['params'])
|
||||
|
||||
|
||||
class TeethAgentAPI(component.APIComponent):
|
||||
"""The primary Teeth Agent API."""
|
||||
|
||||
def __init__(self, agent):
|
||||
super(TeethAgentAPI, self).__init__()
|
||||
self.agent = agent
|
||||
|
||||
def add_routes(self):
|
||||
"""Called during initialization. Override to map relative routes to
|
||||
methods.
|
||||
"""
|
||||
self.route('GET', '/status', self.get_agent_status)
|
||||
self.route('GET', '/commands', self.list_command_results)
|
||||
self.route('POST', '/commands', self.execute_command)
|
||||
self.route('GET',
|
||||
'/commands/<string:result_id>',
|
||||
self.get_command_result)
|
||||
|
||||
def get_agent_status(self, request):
|
||||
"""Get the status of the agent."""
|
||||
return responses.ItemResponse(self.agent.get_status())
|
||||
|
||||
def list_command_results(self, request):
|
||||
# TODO(russellhaering): pagination
|
||||
command_results = self.agent.list_command_results()
|
||||
return responses.PaginatedResponse(request,
|
||||
command_results,
|
||||
self.list_command_results,
|
||||
None,
|
||||
None)
|
||||
|
||||
def execute_command(self, request):
|
||||
"""Execute a command on the agent."""
|
||||
command = AgentCommand.deserialize(self.parse_content(request))
|
||||
result = self.agent.execute_command(command.name, **command.params)
|
||||
|
||||
wait = request.args.get('wait')
|
||||
if wait and wait.lower() == 'true':
|
||||
result.join()
|
||||
|
||||
return responses.ItemResponse(result)
|
||||
|
||||
def get_command_result(self, request, result_id):
|
||||
"""Retrieve the result of a command."""
|
||||
result = self.agent.get_command_result(result_id)
|
||||
|
||||
wait = request.args.get('wait')
|
||||
if wait and wait.lower() == 'true':
|
||||
result.join()
|
||||
|
||||
return responses.ItemResponse(result)
|
||||
|
||||
|
||||
class TeethAgentAPIServer(component.APIServer):
|
||||
"""Server for the teeth agent API."""
|
||||
|
||||
def __init__(self, agent):
|
||||
super(TeethAgentAPIServer, self).__init__()
|
||||
self.add_component('/v1.0', TeethAgentAPI(agent))
|
15
teeth_agent/api/__init__.py
Normal file
15
teeth_agent/api/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""
|
||||
Copyright 2014 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
63
teeth_agent/api/app.py
Normal file
63
teeth_agent/api/app.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""
|
||||
Copyright 2014 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pecan
|
||||
from pecan import hooks
|
||||
|
||||
from teeth_agent.api import config
|
||||
|
||||
|
||||
class AgentHook(hooks.PecanHook):
|
||||
def __init__(self, agent, *args, **kwargs):
|
||||
super(AgentHook, self).__init__(*args, **kwargs)
|
||||
self.agent = agent
|
||||
|
||||
def before(self, state):
|
||||
state.request.agent = self.agent
|
||||
|
||||
|
||||
def get_pecan_config():
|
||||
# Set up the pecan configuration
|
||||
filename = config.__file__.replace('.pyc', '.py')
|
||||
return pecan.configuration.conf_from_file(filename)
|
||||
|
||||
|
||||
def setup_app(agent, pecan_config=None, extra_hooks=None):
|
||||
app_hooks = [AgentHook(agent)]
|
||||
|
||||
if not pecan_config:
|
||||
pecan_config = get_pecan_config()
|
||||
|
||||
pecan.configuration.set_config(dict(pecan_config), overwrite=True)
|
||||
|
||||
app = pecan.make_app(
|
||||
pecan_config.app.root,
|
||||
static_root=pecan_config.app.static_root,
|
||||
debug=pecan_config.app.debug,
|
||||
force_canonical=getattr(pecan_config.app, 'force_canonical', True),
|
||||
hooks=app_hooks,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class VersionSelectorApplication(object):
|
||||
def __init__(self, agent):
|
||||
pc = get_pecan_config()
|
||||
self.v1 = setup_app(agent, pecan_config=pc)
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
return self.v1(environ, start_response)
|
0
teeth_agent/api/app.wsgi
Normal file
0
teeth_agent/api/app.wsgi
Normal file
39
teeth_agent/api/config.py
Normal file
39
teeth_agent/api/config.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""
|
||||
Copyright 2014 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
# Server Specific Configurations
|
||||
# See https://pecan.readthedocs.org/en/latest/configuration.html#server-configuration # noqa
|
||||
server = {
|
||||
'port': '9999',
|
||||
'host': '0.0.0.0'
|
||||
}
|
||||
|
||||
# Pecan Application Configurations
|
||||
# See https://pecan.readthedocs.org/en/latest/configuration.html#application-configuration # noqa
|
||||
app = {
|
||||
'root': 'teeth_agent.api.controllers.root.RootController',
|
||||
'modules': ['teeth_agent.api'],
|
||||
'static_root': '%(confdir)s/public',
|
||||
'debug': False,
|
||||
'enable_acl': True,
|
||||
'acl_public_routes': ['/', '/v1'],
|
||||
}
|
||||
|
||||
# WSME Configurations
|
||||
# See https://wsme.readthedocs.org/en/latest/integrate.html#configuration
|
||||
wsme = {
|
||||
'debug': False,
|
||||
}
|
15
teeth_agent/api/controllers/__init__.py
Normal file
15
teeth_agent/api/controllers/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""
|
||||
Copyright 2014 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
96
teeth_agent/api/controllers/root.py
Normal file
96
teeth_agent/api/controllers/root.py
Normal file
@ -0,0 +1,96 @@
|
||||
# Copyright 2014 Rackspace, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pecan
|
||||
from pecan import rest
|
||||
|
||||
from wsme import types as wtypes
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
from teeth_agent.api.controllers import v1
|
||||
from teeth_agent.api.controllers.v1 import base
|
||||
from teeth_agent.api.controllers.v1 import link
|
||||
|
||||
|
||||
class Version(base.APIBase):
|
||||
"""An API version representation."""
|
||||
|
||||
id = wtypes.text
|
||||
"The ID of the version, also acts as the release number"
|
||||
|
||||
links = [link.Link]
|
||||
"A Link that point to a specific version of the API"
|
||||
|
||||
@classmethod
|
||||
def convert(self, id):
|
||||
version = Version()
|
||||
version.id = id
|
||||
version.links = [link.Link.make_link('self', pecan.request.host_url,
|
||||
id, '', bookmark=True)]
|
||||
return version
|
||||
|
||||
|
||||
class Root(base.APIBase):
|
||||
|
||||
name = wtypes.text
|
||||
"The name of the API"
|
||||
|
||||
description = wtypes.text
|
||||
"Some information about this API"
|
||||
|
||||
versions = [Version]
|
||||
"Links to all the versions available in this API"
|
||||
|
||||
default_version = Version
|
||||
"A link to the default version of the API"
|
||||
|
||||
@classmethod
|
||||
def convert(self):
|
||||
root = Root()
|
||||
root.name = 'OpenStack Ironic Python Agent API'
|
||||
root.description = ('Ironic Python Agent is a provisioning agent for '
|
||||
'OpenStack Ironic')
|
||||
root.versions = [Version.convert('v1')]
|
||||
root.default_version = Version.convert('v1')
|
||||
return root
|
||||
|
||||
|
||||
class RootController(rest.RestController):
|
||||
|
||||
_versions = ['v1']
|
||||
"All supported API versions"
|
||||
|
||||
_default_version = 'v1'
|
||||
"The default API version"
|
||||
|
||||
v1 = v1.Controller()
|
||||
|
||||
@wsme_pecan.wsexpose(Root)
|
||||
def get(self):
|
||||
# NOTE: The reason why convert() it's being called for every
|
||||
# request is because we need to get the host url from
|
||||
# the request object to make the links.
|
||||
return Root.convert()
|
||||
|
||||
@pecan.expose()
|
||||
def _route(self, args):
|
||||
"""Overrides the default routing behavior.
|
||||
|
||||
It redirects the request to the default version of the ironic API
|
||||
if the version number is not specified in the url.
|
||||
"""
|
||||
|
||||
if args[0] and args[0] not in self._versions:
|
||||
args = [self._default_version] + args
|
||||
return super(RootController, self)._route(args)
|
118
teeth_agent/api/controllers/v1/__init__.py
Normal file
118
teeth_agent/api/controllers/v1/__init__.py
Normal file
@ -0,0 +1,118 @@
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Version 1 of the Ironic Python Agent API
|
||||
"""
|
||||
|
||||
import pecan
|
||||
from pecan import rest
|
||||
|
||||
from wsme import types as wtypes
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
from teeth_agent.api.controllers.v1 import base
|
||||
from teeth_agent.api.controllers.v1 import command
|
||||
from teeth_agent.api.controllers.v1 import link
|
||||
from teeth_agent.api.controllers.v1 import status
|
||||
|
||||
|
||||
class MediaType(base.APIBase):
|
||||
"""A media type representation."""
|
||||
|
||||
base = wtypes.text
|
||||
type = wtypes.text
|
||||
|
||||
def __init__(self, base, type):
|
||||
self.base = base
|
||||
self.type = type
|
||||
|
||||
|
||||
class V1(base.APIBase):
|
||||
"""The representation of the version 1 of the API."""
|
||||
|
||||
id = wtypes.text
|
||||
"The ID of the version, also acts as the release number"
|
||||
|
||||
media_types = [MediaType]
|
||||
"An array of supported media types for this version"
|
||||
|
||||
links = [link.Link]
|
||||
"Links that point to a specific URL for this version and documentation"
|
||||
|
||||
commands = [link.Link]
|
||||
"Links to the command resource"
|
||||
|
||||
status = [link.Link]
|
||||
"Links to the status resource"
|
||||
|
||||
@classmethod
|
||||
def convert(self):
|
||||
v1 = V1()
|
||||
v1.id = "v1"
|
||||
v1.links = [
|
||||
link.Link.make_link('self',
|
||||
pecan.request.host_url,
|
||||
'v1',
|
||||
'',
|
||||
bookmark=True),
|
||||
link.Link.make_link('describedby',
|
||||
'https://github.com',
|
||||
'rackerlabs',
|
||||
'teeth-agent',
|
||||
bookmark=True,
|
||||
type='text/html')
|
||||
]
|
||||
v1.command = [
|
||||
link.Link.make_link('self',
|
||||
pecan.request.host_url,
|
||||
'commands',
|
||||
''),
|
||||
link.Link.make_link('bookmark',
|
||||
pecan.request.host_url,
|
||||
'commands',
|
||||
'',
|
||||
bookmark=True)
|
||||
]
|
||||
v1.status = [
|
||||
link.Link.make_link('self',
|
||||
pecan.request.host_url,
|
||||
'status',
|
||||
''),
|
||||
link.Link.make_link('bookmark',
|
||||
pecan.request.host_url,
|
||||
'status',
|
||||
'',
|
||||
bookmark=True)
|
||||
]
|
||||
v1.media_types = [MediaType('application/json',
|
||||
('application/vnd.openstack.'
|
||||
'ironic-python-agent.v1+json'))]
|
||||
return v1
|
||||
|
||||
|
||||
class Controller(rest.RestController):
|
||||
"""Version 1 API controller root."""
|
||||
|
||||
commands = command.CommandController()
|
||||
status = status.StatusController()
|
||||
|
||||
@wsme_pecan.wsexpose(V1)
|
||||
def get(self):
|
||||
# NOTE: The reason why convert() it's being called for every
|
||||
# request is because we need to get the host url from
|
||||
# the request object to make the links.
|
||||
return V1.convert()
|
||||
|
||||
__all__ = (Controller)
|
73
teeth_agent/api/controllers/v1/base.py
Normal file
73
teeth_agent/api/controllers/v1/base.py
Normal file
@ -0,0 +1,73 @@
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import six
|
||||
from wsme import types as wtypes
|
||||
|
||||
|
||||
class ExceptionType(wtypes.UserType):
|
||||
basetype = wtypes.DictType
|
||||
name = 'exception'
|
||||
|
||||
def validate(self, value):
|
||||
if not isinstance(value, BaseException):
|
||||
raise ValueError('Value is not an exception')
|
||||
return value
|
||||
|
||||
def tobasetype(self, value):
|
||||
"""Turn a RESTError into a dict."""
|
||||
return {
|
||||
'type': value.__class__.__name__,
|
||||
'code': value.status_code,
|
||||
'message': value.message,
|
||||
'details': value.details,
|
||||
}
|
||||
|
||||
frombasetype = tobasetype
|
||||
|
||||
|
||||
exception_type = ExceptionType()
|
||||
|
||||
|
||||
class MultiType(wtypes.UserType):
|
||||
"""A complex type that represents one or more types.
|
||||
|
||||
Used for validating that a value is an instance of one of the types.
|
||||
|
||||
:param *types: Variable-length list of types.
|
||||
|
||||
"""
|
||||
def __init__(self, *types):
|
||||
self.types = types
|
||||
|
||||
def __str__(self):
|
||||
return ' | '.join(map(str, self.types))
|
||||
|
||||
def validate(self, value):
|
||||
for t in self.types:
|
||||
if t is wtypes.text and isinstance(value, wtypes.bytes):
|
||||
value = value.decode()
|
||||
if isinstance(value, t):
|
||||
return value
|
||||
else:
|
||||
raise ValueError(
|
||||
"Wrong type. Expected '{type}', got '{value}'".format(
|
||||
type=self.types, value=type(value)))
|
||||
|
||||
|
||||
json_type = MultiType(list, dict, six.integer_types, wtypes.text)
|
||||
|
||||
|
||||
class APIBase(wtypes.Base):
|
||||
pass
|
89
teeth_agent/api/controllers/v1/command.py
Normal file
89
teeth_agent/api/controllers/v1/command.py
Normal file
@ -0,0 +1,89 @@
|
||||
# Copyright 2014 Rackspace, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pecan
|
||||
from pecan import rest
|
||||
from wsme import types
|
||||
from wsmeext import pecan as wsme_pecan
|
||||
|
||||
from teeth_agent.api.controllers.v1 import base
|
||||
|
||||
|
||||
class CommandResult(base.APIBase):
|
||||
id = types.text
|
||||
command_name = types.text
|
||||
command_params = types.DictType(types.text, base.json_type)
|
||||
command_status = types.text
|
||||
command_error = base.exception_type
|
||||
command_result = types.DictType(types.text, base.json_type)
|
||||
|
||||
@classmethod
|
||||
def from_result(cls, result):
|
||||
instance = cls()
|
||||
for field in ('id', 'command_name', 'command_params', 'command_status',
|
||||
'command_error', 'command_result'):
|
||||
setattr(instance, field, getattr(result, field))
|
||||
return instance
|
||||
|
||||
|
||||
class CommandResultList(base.APIBase):
|
||||
commands = [CommandResult]
|
||||
|
||||
@classmethod
|
||||
def from_results(cls, results):
|
||||
instance = cls()
|
||||
instance.commands = [CommandResult.from_result(result)
|
||||
for result in results]
|
||||
return instance
|
||||
|
||||
|
||||
class Command(base.APIBase):
|
||||
"""A command representation."""
|
||||
name = types.wsattr(types.text, mandatory=True)
|
||||
params = types.wsattr(base.MultiType(dict), mandatory=True)
|
||||
|
||||
|
||||
class CommandController(rest.RestController):
|
||||
"""Controller for issuing commands and polling for command status."""
|
||||
|
||||
@wsme_pecan.wsexpose(CommandResultList)
|
||||
def get_all(self):
|
||||
agent = pecan.request.agent
|
||||
results = agent.list_command_results()
|
||||
return CommandResultList.from_results(results)
|
||||
|
||||
@wsme_pecan.wsexpose(CommandResult, types.text, types.text)
|
||||
def get_one(self, result_id, wait=False):
|
||||
agent = pecan.request.agent
|
||||
result = agent.get_command_result(result_id)
|
||||
|
||||
if wait and wait.lower() == 'true':
|
||||
result.join()
|
||||
|
||||
return CommandResult.from_result(result)
|
||||
|
||||
@wsme_pecan.wsexpose(CommandResult, body=Command)
|
||||
def post(self, wait=False, command=None):
|
||||
# the POST body is always the last arg,
|
||||
# so command must be a kwarg here
|
||||
if command is None:
|
||||
command = Command()
|
||||
agent = pecan.request.agent
|
||||
result = agent.execute_command(command.name, **command.params)
|
||||
|
||||
if wait and wait.lower() == 'true':
|
||||
result.join()
|
||||
|
||||
return result
|
43
teeth_agent/api/controllers/v1/link.py
Normal file
43
teeth_agent/api/controllers/v1/link.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Copyright 2014 Rackspace, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from wsme import types as wtypes
|
||||
|
||||
from teeth_agent.api.controllers.v1 import base
|
||||
|
||||
|
||||
class Link(base.APIBase):
|
||||
"""A link representation."""
|
||||
|
||||
href = wtypes.text
|
||||
"The url of a link."
|
||||
|
||||
rel = wtypes.text
|
||||
"The name of a link."
|
||||
|
||||
type = wtypes.text
|
||||
"Indicates the type of document/link."
|
||||
|
||||
@classmethod
|
||||
def make_link(cls, rel_name, url, resource, resource_args,
|
||||
bookmark=False, type=wtypes.Unset):
|
||||
template = '%s/%s' if bookmark else '%s/v1/%s'
|
||||
# FIXME(lucasagomes): I'm getting a 404 when doing a GET on
|
||||
# a nested resource that the URL ends with a '/'.
|
||||
# https://groups.google.com/forum/#!topic/pecan-dev/QfSeviLg5qs
|
||||
template += '%s' if resource_args.startswith('?') else '/%s'
|
||||
|
||||
return Link(href=(template) % (url, resource, resource_args),
|
||||
rel=rel_name, type=type)
|
44
teeth_agent/api/controllers/v1/status.py
Normal file
44
teeth_agent/api/controllers/v1/status.py
Normal file
@ -0,0 +1,44 @@
|
||||
# Copyright 2014 Rackspace, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pecan
|
||||
from pecan import rest
|
||||
from wsme import types
|
||||
from wsmeext import pecan as wsme_pecan
|
||||
|
||||
from teeth_agent.api.controllers.v1 import base
|
||||
|
||||
|
||||
class AgentStatus(base.APIBase):
|
||||
mode = types.text
|
||||
started_at = base.MultiType(float)
|
||||
version = types.text
|
||||
|
||||
@classmethod
|
||||
def from_agent_status(cls, status):
|
||||
instance = cls()
|
||||
for field in ('mode', 'started_at', 'version'):
|
||||
setattr(instance, field, getattr(status, field))
|
||||
return instance
|
||||
|
||||
|
||||
class StatusController(rest.RestController):
|
||||
"""Controller for getting agent status."""
|
||||
|
||||
@wsme_pecan.wsexpose(AgentStatus)
|
||||
def get_all(self):
|
||||
agent = pecan.request.agent
|
||||
status = agent.get_status()
|
||||
return AgentStatus.from_agent_status(status)
|
@ -18,37 +18,35 @@ import threading
|
||||
import uuid
|
||||
|
||||
import structlog
|
||||
from teeth_rest import encoding
|
||||
from teeth_rest import errors as rest_errors
|
||||
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import errors
|
||||
from teeth_agent import utils
|
||||
|
||||
|
||||
class AgentCommandStatus(object):
|
||||
RUNNING = 'RUNNING'
|
||||
SUCCEEDED = 'SUCCEEDED'
|
||||
FAILED = 'FAILED'
|
||||
RUNNING = u'RUNNING'
|
||||
SUCCEEDED = u'SUCCEEDED'
|
||||
FAILED = u'FAILED'
|
||||
|
||||
|
||||
class BaseCommandResult(encoding.Serializable):
|
||||
def __init__(self, command_name, command_params):
|
||||
self.id = str(uuid.uuid4())
|
||||
self.id = unicode(uuid.uuid4())
|
||||
self.command_name = command_name
|
||||
self.command_params = command_params
|
||||
self.command_status = AgentCommandStatus.RUNNING
|
||||
self.command_error = None
|
||||
self.command_result = None
|
||||
|
||||
def serialize(self, view):
|
||||
return utils.get_ordereddict([
|
||||
('id', self.id),
|
||||
('command_name', self.command_name),
|
||||
('command_params', self.command_params),
|
||||
('command_status', self.command_status),
|
||||
('command_error', self.command_error),
|
||||
('command_result', self.command_result),
|
||||
])
|
||||
def serialize(self):
|
||||
return dict((
|
||||
(u'id', self.id),
|
||||
(u'command_name', self.command_name),
|
||||
(u'command_params', self.command_params),
|
||||
(u'command_status', self.command_status),
|
||||
(u'command_error', self.command_error),
|
||||
(u'command_result', self.command_result),
|
||||
))
|
||||
|
||||
def is_done(self):
|
||||
return self.command_status != AgentCommandStatus.RUNNING
|
||||
@ -83,9 +81,9 @@ class AsyncCommandResult(BaseCommandResult):
|
||||
self.execution_thread = threading.Thread(target=self.run,
|
||||
name=thread_name)
|
||||
|
||||
def serialize(self, view):
|
||||
def serialize(self):
|
||||
with self.command_state_lock:
|
||||
return super(AsyncCommandResult, self).serialize(view)
|
||||
return super(AsyncCommandResult, self).serialize()
|
||||
|
||||
def start(self):
|
||||
self.execution_thread.start()
|
||||
@ -108,7 +106,7 @@ class AsyncCommandResult(BaseCommandResult):
|
||||
self.command_status = AgentCommandStatus.SUCCEEDED
|
||||
|
||||
except Exception as e:
|
||||
if not isinstance(e, rest_errors.RESTError):
|
||||
if not isinstance(e, errors.RESTError):
|
||||
e = errors.CommandExecutionError(str(e))
|
||||
|
||||
with self.command_state_lock:
|
||||
|
@ -39,8 +39,13 @@ def run():
|
||||
type=int,
|
||||
help='The port to listen on')
|
||||
|
||||
parser.add_argument('--ipaddr',
|
||||
required=True,
|
||||
help='The external IP address to advertise to ironic')
|
||||
|
||||
args = parser.parse_args()
|
||||
log.configure()
|
||||
agent.build_agent(args.api_url,
|
||||
args.listen_host,
|
||||
args.listen_port).run()
|
||||
args.listen_port,
|
||||
args.ipaddr).run()
|
||||
|
53
teeth_agent/encoding.py
Normal file
53
teeth_agent/encoding.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""
|
||||
Copyright 2013 Rackspace, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
class Serializable(object):
|
||||
"""Base class for things that can be serialized."""
|
||||
def serialize(self):
|
||||
"""Turn this object into a dict."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class RESTJSONEncoder(json.JSONEncoder):
|
||||
"""A slightly customized JSON encoder."""
|
||||
def encode(self, o):
|
||||
"""Turn an object into JSON.
|
||||
|
||||
Appends a newline to responses when configured to pretty-print,
|
||||
in order to make use of curl less painful from most shells.
|
||||
"""
|
||||
delimiter = ''
|
||||
|
||||
# if indent is None, newlines are still inserted, so we should too.
|
||||
if self.indent is not None:
|
||||
delimiter = '\n'
|
||||
|
||||
return super(RESTJSONEncoder, self).encode(o) + delimiter
|
||||
|
||||
def default(self, o):
|
||||
"""Turn an object into a serializable object. In particular, by
|
||||
calling :meth:`.Serializable.serialize`.
|
||||
"""
|
||||
if isinstance(o, Serializable):
|
||||
return o.serialize()
|
||||
elif isinstance(o, uuid.UUID):
|
||||
return str(o)
|
||||
else:
|
||||
return json.JSONEncoder.default(self, o)
|
@ -14,10 +14,49 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from teeth_rest import errors
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import utils
|
||||
|
||||
|
||||
class CommandExecutionError(errors.RESTError):
|
||||
class RESTError(Exception, encoding.Serializable):
|
||||
"""Base class for errors generated in teeth."""
|
||||
message = 'An error occurred'
|
||||
details = 'An unexpected error occurred. Please try back later.'
|
||||
status_code = 500
|
||||
|
||||
def serialize(self):
|
||||
"""Turn a RESTError into a dict."""
|
||||
return utils.get_ordereddict([
|
||||
('type', self.__class__.__name__),
|
||||
('code', self.status_code),
|
||||
('message', self.message),
|
||||
('details', self.details),
|
||||
])
|
||||
|
||||
|
||||
class InvalidContentError(RESTError):
|
||||
"""Error which occurs when a user supplies invalid content, either
|
||||
because that content cannot be parsed according to the advertised
|
||||
`Content-Type`, or due to a content validation error.
|
||||
"""
|
||||
message = 'Invalid request body'
|
||||
status_code = 400
|
||||
|
||||
def __init__(self, details):
|
||||
self.details = details
|
||||
|
||||
|
||||
class NotFound(RESTError):
|
||||
"""Error which occurs when a user supplies invalid content, either
|
||||
because that content cannot be parsed according to the advertised
|
||||
`Content-Type`, or due to a content validation error.
|
||||
"""
|
||||
message = 'Not found'
|
||||
status_code = 404
|
||||
details = 'The requested URL was not found.'
|
||||
|
||||
|
||||
class CommandExecutionError(RESTError):
|
||||
"""Error raised when a command fails to execute."""
|
||||
|
||||
message = 'Command execution failed'
|
||||
@ -27,7 +66,7 @@ class CommandExecutionError(errors.RESTError):
|
||||
self.details = details
|
||||
|
||||
|
||||
class InvalidCommandError(errors.InvalidContentError):
|
||||
class InvalidCommandError(InvalidContentError):
|
||||
"""Error which is raised when an unknown command is issued."""
|
||||
|
||||
messsage = 'Invalid command'
|
||||
@ -36,7 +75,7 @@ class InvalidCommandError(errors.InvalidContentError):
|
||||
super(InvalidCommandError, self).__init__(details)
|
||||
|
||||
|
||||
class InvalidCommandParamsError(errors.InvalidContentError):
|
||||
class InvalidCommandParamsError(InvalidContentError):
|
||||
"""Error which is raised when command parameters are invalid."""
|
||||
|
||||
message = 'Invalid command parameters'
|
||||
@ -45,14 +84,14 @@ class InvalidCommandParamsError(errors.InvalidContentError):
|
||||
super(InvalidCommandParamsError, self).__init__(details)
|
||||
|
||||
|
||||
class RequestedObjectNotFoundError(errors.NotFound):
|
||||
class RequestedObjectNotFoundError(NotFound):
|
||||
def __init__(self, type_descr, obj_id):
|
||||
details = '{0} with id {1} not found.'.format(type_descr, obj_id)
|
||||
super(RequestedObjectNotFoundError, self).__init__(details)
|
||||
self.details = details
|
||||
|
||||
|
||||
class OverlordAPIError(errors.RESTError):
|
||||
class OverlordAPIError(RESTError):
|
||||
"""Error raised when a call to the agent API fails."""
|
||||
|
||||
message = 'Error in call to teeth-agent-api.'
|
||||
@ -71,7 +110,7 @@ class HeartbeatError(OverlordAPIError):
|
||||
super(HeartbeatError, self).__init__(details)
|
||||
|
||||
|
||||
class ImageDownloadError(errors.RESTError):
|
||||
class ImageDownloadError(RESTError):
|
||||
"""Error raised when an image cannot be downloaded."""
|
||||
|
||||
message = 'Error downloading image.'
|
||||
@ -81,7 +120,7 @@ class ImageDownloadError(errors.RESTError):
|
||||
self.details = 'Could not download image with id {0}.'.format(image_id)
|
||||
|
||||
|
||||
class ImageChecksumError(errors.RESTError):
|
||||
class ImageChecksumError(RESTError):
|
||||
"""Error raised when an image fails to verify against its checksum."""
|
||||
|
||||
message = 'Error verifying image checksum.'
|
||||
@ -92,7 +131,7 @@ class ImageChecksumError(errors.RESTError):
|
||||
self.details = self.details.format(image_id)
|
||||
|
||||
|
||||
class ImageWriteError(errors.RESTError):
|
||||
class ImageWriteError(RESTError):
|
||||
"""Error raised when an image cannot be written to a device."""
|
||||
|
||||
message = 'Error writing image to device.'
|
||||
@ -103,7 +142,7 @@ class ImageWriteError(errors.RESTError):
|
||||
self.details = self.details.format(device, exit_code)
|
||||
|
||||
|
||||
class ConfigDriveWriteError(errors.RESTError):
|
||||
class ConfigDriveWriteError(RESTError):
|
||||
"""Error raised when a configdrive directory cannot be written to a
|
||||
device.
|
||||
"""
|
||||
@ -118,7 +157,7 @@ class ConfigDriveWriteError(errors.RESTError):
|
||||
self.details = details
|
||||
|
||||
|
||||
class SystemRebootError(errors.RESTError):
|
||||
class SystemRebootError(RESTError):
|
||||
"""Error raised when a system cannot reboot."""
|
||||
|
||||
message = 'Error rebooting system.'
|
||||
|
@ -21,10 +21,9 @@ import subprocess
|
||||
import stevedore
|
||||
import structlog
|
||||
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import utils
|
||||
|
||||
from teeth_rest import encoding
|
||||
|
||||
_global_manager = None
|
||||
|
||||
|
||||
@ -50,7 +49,7 @@ class HardwareInfo(encoding.Serializable):
|
||||
self.type = type
|
||||
self.id = id
|
||||
|
||||
def serialize(self, view):
|
||||
def serialize(self):
|
||||
return utils.get_ordereddict([
|
||||
('type', self.type),
|
||||
('id', self.id),
|
||||
|
@ -38,7 +38,7 @@ def _format_event(logger, method, event):
|
||||
have enough keys to format.
|
||||
"""
|
||||
if 'event' not in event:
|
||||
# nothing to format, e.g. _log_request in teeth_rest/component
|
||||
# nothing to format
|
||||
return event
|
||||
# Get a list of fields that need to be filled.
|
||||
formatter = string.Formatter()
|
||||
|
@ -17,8 +17,8 @@ limitations under the License.
|
||||
import json
|
||||
|
||||
import requests
|
||||
from teeth_rest import encoding
|
||||
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import errors
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@ class APIClient(object):
|
||||
def __init__(self, api_url):
|
||||
self.api_url = api_url.rstrip('/')
|
||||
self.session = requests.Session()
|
||||
self.encoder = encoding.RESTJSONEncoder(
|
||||
encoding.SerializationViews.PUBLIC)
|
||||
self.encoder = encoding.RESTJSONEncoder()
|
||||
|
||||
def _request(self, method, path, data=None):
|
||||
request_url = '{api_url}{path}'.format(api_url=self.api_url, path=path)
|
||||
|
@ -20,11 +20,12 @@ import unittest
|
||||
|
||||
import mock
|
||||
import pkg_resources
|
||||
from wsgiref import simple_server
|
||||
|
||||
from teeth_rest import encoding
|
||||
|
||||
from teeth_agent import agent
|
||||
from teeth_agent import base
|
||||
from teeth_agent import encoding
|
||||
from teeth_agent import errors
|
||||
from teeth_agent import hardware
|
||||
|
||||
@ -117,11 +118,10 @@ class TestHeartbeater(unittest.TestCase):
|
||||
|
||||
class TestBaseAgent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.encoder = encoding.RESTJSONEncoder(
|
||||
encoding.SerializationViews.PUBLIC,
|
||||
indent=4)
|
||||
self.encoder = encoding.RESTJSONEncoder(indent=4)
|
||||
self.agent = agent.TeethAgent('https://fake_api.example.org:8081/',
|
||||
('localhost', 9999))
|
||||
('localhost', 9999),
|
||||
'192.168.1.1')
|
||||
|
||||
def assertEqualEncoded(self, a, b):
|
||||
# Evidently JSONEncoder.default() can't handle None (??) so we have to
|
||||
@ -136,7 +136,7 @@ class TestBaseAgent(unittest.TestCase):
|
||||
self.agent.started_at = started_at
|
||||
|
||||
status = self.agent.get_status()
|
||||
self.assertIsInstance(status, agent.TeethAgentStatus)
|
||||
self.assertTrue(isinstance(status, agent.TeethAgentStatus))
|
||||
self.assertEqual(status.started_at, started_at)
|
||||
self.assertEqual(status.version,
|
||||
pkg_resources.get_distribution('teeth-agent').version)
|
||||
@ -156,7 +156,7 @@ class TestBaseAgent(unittest.TestCase):
|
||||
'do_something',
|
||||
foo='bar')
|
||||
|
||||
@mock.patch('cherrypy.wsgiserver.CherryPyWSGIServer', autospec=True)
|
||||
@mock.patch('wsgiref.simple_server.make_server', autospec=True)
|
||||
def test_run(self, wsgi_server_cls):
|
||||
wsgi_server = wsgi_server_cls.return_value
|
||||
wsgi_server.start.side_effect = KeyboardInterrupt()
|
||||
@ -165,9 +165,12 @@ class TestBaseAgent(unittest.TestCase):
|
||||
self.agent.run()
|
||||
|
||||
listen_addr = ('localhost', 9999)
|
||||
wsgi_server_cls.assert_called_once_with(listen_addr, self.agent.api)
|
||||
wsgi_server.start.assert_called_once_with()
|
||||
wsgi_server.stop.assert_called_once_with()
|
||||
wsgi_server_cls.assert_called_once_with(
|
||||
listen_addr[0],
|
||||
listen_addr[1],
|
||||
self.agent.api,
|
||||
server_class=simple_server.WSGIServer)
|
||||
wsgi_server.serve_forever.assert_called_once()
|
||||
|
||||
self.agent.heartbeater.start.assert_called_once_with()
|
||||
|
||||
|
@ -22,10 +22,9 @@ import unittest
|
||||
from werkzeug import test
|
||||
from werkzeug import wrappers
|
||||
|
||||
from teeth_rest import encoding
|
||||
|
||||
from teeth_agent import agent
|
||||
from teeth_agent import api
|
||||
from teeth_agent.api import app
|
||||
from teeth_agent import base
|
||||
|
||||
|
||||
@ -44,13 +43,27 @@ class TestTeethAPI(unittest.TestCase):
|
||||
client = test.Client(api, wrappers.BaseResponse)
|
||||
return client.open(self._get_env_builder(method, path, data, query))
|
||||
|
||||
def test_root(self):
|
||||
mock_agent = mock.MagicMock()
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
response = self._make_request(api_server, 'GET', '/')
|
||||
self.assertEqual(response.status, '200 OK')
|
||||
|
||||
def test_v1_root(self):
|
||||
mock_agent = mock.MagicMock()
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
response = self._make_request(api_server, 'GET', '/v1')
|
||||
self.assertEqual(response.status, '200 OK')
|
||||
|
||||
def test_get_agent_status(self):
|
||||
status = agent.TeethAgentStatus('TEST_MODE', time.time(), 'v72ac9')
|
||||
mock_agent = mock.MagicMock()
|
||||
mock_agent.get_status.return_value = status
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
response = self._make_request(api_server, 'GET', '/v1.0/status')
|
||||
response = self._make_request(api_server, 'GET', '/v1/status')
|
||||
mock_agent.get_status.assert_called_once_with()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@ -72,11 +85,11 @@ class TestTeethAPI(unittest.TestCase):
|
||||
|
||||
mock_agent = mock.MagicMock()
|
||||
mock_agent.execute_command.return_value = result
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
response = self._make_request(api_server,
|
||||
'POST',
|
||||
'/v1.0/commands',
|
||||
'/v1/commands/',
|
||||
data=command)
|
||||
|
||||
self.assertEqual(mock_agent.execute_command.call_count, 1)
|
||||
@ -85,55 +98,59 @@ class TestTeethAPI(unittest.TestCase):
|
||||
self.assertEqual(kwargs, {'key': 'value'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
expected_result = result.serialize(encoding.SerializationViews.PUBLIC)
|
||||
expected_result = result.serialize()
|
||||
self.assertEqual(data, expected_result)
|
||||
|
||||
def test_execute_agent_command_validation(self):
|
||||
mock_agent = mock.MagicMock()
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
invalid_command = {}
|
||||
response = self._make_request(api_server,
|
||||
'POST',
|
||||
'/v1.0/commands',
|
||||
'/v1/commands',
|
||||
data=invalid_command)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['details'], 'Missing command \'name\' field.')
|
||||
msg = 'Invalid input for field/attribute name.'
|
||||
self.assertTrue(msg in data['faultstring'])
|
||||
msg = 'Mandatory field missing'
|
||||
self.assertTrue(msg in data['faultstring'])
|
||||
|
||||
def test_execute_agent_command_params_validation(self):
|
||||
mock_agent = mock.MagicMock()
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
api_server = app.setup_app(mock_agent)
|
||||
|
||||
invalid_command = {'name': 'do_things', 'params': []}
|
||||
response = self._make_request(api_server,
|
||||
'POST',
|
||||
'/v1.0/commands',
|
||||
'/v1/commands',
|
||||
data=invalid_command)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['details'],
|
||||
'Command params must be a dictionary.')
|
||||
# this message is actually much longer, but I'm ok with this
|
||||
msg = 'Invalid input for field/attribute params.'
|
||||
self.assertTrue(msg in data['faultstring'])
|
||||
|
||||
def test_list_command_results(self):
|
||||
cmd_result = base.SyncCommandResult('do_things',
|
||||
{'key': 'value'},
|
||||
self.maxDiff = 10000
|
||||
cmd_result = base.SyncCommandResult(u'do_things',
|
||||
{u'key': u'value'},
|
||||
True,
|
||||
{'test': 'result'})
|
||||
{u'test': u'result'})
|
||||
|
||||
mock_agent = mock.create_autospec(agent.TeethAgent)
|
||||
mock_agent.list_command_results.return_value = [
|
||||
cmd_result,
|
||||
]
|
||||
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
response = self._make_request(api_server, 'GET', '/v1.0/commands')
|
||||
api_server = app.setup_app(mock_agent)
|
||||
response = self._make_request(api_server, 'GET', '/v1/commands')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(json.loads(response.data), {
|
||||
'items': [
|
||||
cmd_result.serialize(encoding.SerializationViews.PUBLIC),
|
||||
u'commands': [
|
||||
cmd_result.serialize(),
|
||||
],
|
||||
'links': [],
|
||||
})
|
||||
|
||||
def test_get_command_result(self):
|
||||
@ -142,16 +159,15 @@ class TestTeethAPI(unittest.TestCase):
|
||||
True,
|
||||
{'test': 'result'})
|
||||
|
||||
serialized_cmd_result = cmd_result.serialize(
|
||||
encoding.SerializationViews.PUBLIC)
|
||||
serialized_cmd_result = cmd_result.serialize()
|
||||
|
||||
mock_agent = mock.create_autospec(agent.TeethAgent)
|
||||
mock_agent.get_command_result.return_value = cmd_result
|
||||
|
||||
api_server = api.TeethAgentAPIServer(mock_agent)
|
||||
api_server = app.setup_app(mock_agent)
|
||||
response = self._make_request(api_server,
|
||||
'GET',
|
||||
'/v1.0/commands/abc123')
|
||||
'/v1/commands/abc123')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data, serialized_cmd_result)
|
||||
|
Loading…
Reference in New Issue
Block a user