Contestants in the example programming contest create their solutions, called players, as web services that make one move at a time. Questioners are programs that have those players play the game. The manager accepts requests to play a new round of games, and displays current results.
Contest participants propose their solutions for scoring to the manager, which records the submission and sends a message to a Pub/Sub topic that results in all registered questioners exercising that player. Each questioner reports its result back to the manager, which updates its data about that submission with results. Any contest participant can ask the manager for the current results of all contest participants by viewing a web page.
The manager can be viewed as having two major parts: a web application that people use to submit their player information and view results, and a web service that questioners use to submit results of a contest round. The web application and the web service share data via Cloud Firestore.
We will create that web application with App Engine and make it available to authenticated users only, so contestants cannot impersonate other contestants. The web service will be an HTTP-triggered Cloud Function that accepts requests from any source, but will verify that requests are authorized by checking that they know the shared secret the manager gave them when they were triggered.
In this tutorial, you will create:
Each of these parts will be built as a separate step in this tutorial
All software deployed on Google Cloud Platform will be part of a project. The manager must be able to publish messages to the Pub/Sub topic used by the questioners, which is most easily enabled if they are part of the same project. You can use an existing project or you can create a new project at console.cloud.google.com.
You will work in the Cloud Shell command line environment. Start by opening that environment and fetching the sample code to it.
Open the Cloud Console at console.cloud.google.com and select your project.
All commands in this codelab will be executed in the console UI or within a Cloud Shell. Open the Cloud Shell by clicking the Activate Cloud Shell icon found at the right side of the console page header. The lower half of the page will allow you to enter and run commands. The commands could be run from your own PC, but you would have to install and configure needed development software first. The Cloud Shell already has all the software tools you need. |
If you have not previously retrieved the code in another codelab, use the following command from the Cloud Shell command line:
git clone https://github.com/GoogleCloudPlatform/serverless-game-contest.git
You can explore the code in the built-in code editor by clicking the editor icon to launch it. |
Cloud Firestore is a NoSQL database that the manager components will use for shared, persistent data. The database contains collections of documents. Each document can contain data and sub-collections. The structure this app will be using is shown below:
Each time a contestant submits a problem solution, a new document will be created in the rounds collection. The document will have a unique identifier (used as a contest_round identifier) assigned to it along with a random secret. The nickname that the contestant has chosen is also stored.
Whenever a questioner finishes a game against a player and submits the result, a new document will be added to the runs sub-collection of that document. This new runs document will specify the questioner, outcome, and how many moves it played.
Cloud Firestore must be enabled via the console or the gcloud
CLI before it can be used. To enable Cloud Firestore from the console, use the following steps:
It will take a few minutes to initialize the database. You can go on to the next step while waiting.
You create a Cloud Function triggered by HTTP requests, which will share a Firestore database with the web app created in the next step.
Use the displayed Create function page to specify your new Cloud Function.
If something goes wrong: the spinner icon will show a red exclamation point. Click the function name and look at the details of the Deployment failure messages in the General tab to figure out how to fix it and try again.
This function will be sent an HTTP POST request whose body is a JSON object with the result of a single contest round.
It will begin by extracting the information from the POST request:
import json
from google.cloud import firestore
def save_result(request):
result = request.get_json()
contest_round = result['contest_round']
outcome = result['outcome']
moves = result['moves']
questioner = result['questioner']
secret = result['secret']
A valid result must be for a contest_round that the manager created, and must know the random secret the manager assigned:
rounds = firestore.Client().collection('rounds')
this_round = rounds.document(contest_round).get()
if not this_round.exists:
return '404' # Not found - no such contest_round was ever asked for
if secret != this_round.to_dict().get('secret'):
return '403' # Forbidden - they don't know the shared secret
If everything is okay, it will save the result of this run:
rounds.document(contest_round).collection('runs').add({
'outcome': outcome,
'moves': moves,
'questioner': questioner,
})
return '201' # Created (a new contest run entry)
Key steps in this function code are:
We are now ready to test the function. Click the function name to open the Function details page, and then click the Testing tab to start. We will start with failing tests because the data is for a never requested run, or is missing the shared secret needed to authenticate the run. We will then set up the requested run information and retry the test, which should succeed.
{
"contest_round": "one",
"secret": "not-very",
"questioner": "easy-questioner",
"outcome": "won",
"moves": 10
}
The function seems to be working as designed.
You will create an App Engine web app that users will interact with, and which will share a Firestore database with the cloud function above, and share a Pub/Sub topic with the questioners.
cd ~/serverless-game-contest/manager/appengine
The application consists of the following files:
We will take a look at these files soon, but first let's deploy the app, which can take a few minutes. Before deploying, we need to change one line in the code to have the URL of the manager Cloud Function created in Step 4:
RESULT_URL = 'https://your-manager-function-url-goes-here'
by replacing the URL in quotes with the actual URL of the manager function you just copied.
You also need to change the line in requirements.txt from:
Flask=1.0.2
to
Flask>1.4.1
Now deploy the edited app:
cd ~/serverless-game-contest/manager/appengine
gcloud app deploy
While waiting for the app to finish deploying, examine the application's files in the App Engine folder. All of the app's code is in main.py. It is a Flask app. When it starts it creates a global app object:
app = Flask(__name__)
The actions to take in response to web requests are decorated with @app.route
giving the request path and HTTP method. For example, the home page has request path /, and is fetched by a browser with the HTTP GET method. Its code is below:
@app.route('/', methods=['GET'])
def echo_recent_results():
results = []
rounds = firestore.Client().collection('rounds')
for contest_round in rounds.order_by(
'timestamp', direction=firestore.Query.DESCENDING
).stream():
round = contest_round.to_dict()
round['contest_round'] = contest_round.id
round['runs'] = []
for run in contest_round.reference.collection('runs').order_by('questioner').stream():
round['runs'].append(run.to_dict())
results.append(round)
page = render_template('index.html', rounds=results)
return page
When a GET request arrives for the home page, the Flask framework calls the echo_recent_results
function. That function builds a data structure called results
, representing the information in the Firestore database, and then returns the index.html template with data from results
filled in.
The returned page is very bare bones in design, but it has the data from the contest rounds. It also includes a link to a page at /request-round
, which is served by the following code:
@app.route('/request-round', methods=['GET'])
def round_form():
nickname = get_nickname()
if nickname is None:
value = ''
attributes = ''
else:
value = nickname
attributes = "readonly disabled"
return render_template(
'round_form.html',
value=value, attributes=attributes
)
This code returns a web page with a form the user can fill out with their chosen nickname and the URL of the player they wrote. However, if the user is one that has already made a prior submission and given a nickname then, this code looks up the already set nickname with get_nickname()
and fills it in the form for them. Finding and tracking the previously set nickname requires the app to be protected by Identity-Aware Proxy. How to do that, and how the code uses that environment to track nicknames, is described in the next step.
When the user submits this form, the browser sends the data via a POST request to the same URL, and it is handled by the start_round
function. It begins by getting the data from the form:
@app.route('/request-round', methods=['POST'])
def start_round():
player_url = request.form.get('player_url')
if player_url is None:
return 'Bad Request: missing player_url', 400
It then gets the user's nickname, from the previously saved value if there is one, otherwise from the submitted form. Notice that, even if the user manages to enter a different nickname than used before, the new one is ignored in favor of the earlier one:
nickname = get_nickname()
if nickname is None: # Never seen this user, accept submitted nickname
nickname = request.form.get('nickname')
if nickname is not None:
set_nickname(nickname)
else:
return 'Bad Request: missing nickname', 400
Again, tracking a user's previously selected nickname will be covered in the next step.
The system keeps tracks of each contest submission via a randomly assigned contest_round identifier. A universally unique identifier (UUID) will avoid accidental reuse of identifiers, and version 4 UUIDs are random, so can't be guessed by looking for patterns. The system wants to be sure that reported results for this round come only from questioners that it triggered, so a random secret is also created. This information is saved in the database so it can be checked when results are reported.
contest_round = str(uuid.uuid4())
secret = str(uuid.uuid4())
timestamp = datetime.utcnow()
firestore.Client().collection('rounds').add({
'nickname': nickname,
'secret': secret,
'timestamp': timestamp,
}, document_id=contest_round)
Finally, the code publishes a message to the topic that the questioners subscribe to, so that each of them will play a game against the newly submitted player:
publisher = pubsub.PublisherClient()
topic_name = 'projects/{project_id}/topics/{topic}'.format(
project_id=os.getenv('GOOGLE_CLOUD_PROJECT'),
topic='play-a-game'
)
payload = {
'contest_round': contest_round,
'player_url': player_url,
'result_url': RESULT_URL,
'secret': secret
}
publisher.publish(topic_name, json.dumps(payload).encode())
return redirect('/', code=302)
Once the message has been published, the code sends the user back to the home page. That page may or may not show some or all of the contest results for the submission, depending on how fast they were developed and saved. In most cases the user would have to refresh the page a few times before every contest run for the submission is finished and displayed.
Your app should be finished deploying by now. In the Cloud Shell command line, type the command gcloud app browse
to see its URL, and open that page to try it out. You should see an empty page of results, be able to navigate to the form for submitting a new player, and after submitting your own player's URL, see the results show up, eventually, on the home page.
If something goes wrong: Open a new browser tab in the cloud console and navigate to Logging/Logs Explorer in the Operations section. You can look for error messages and tracebacks here, usually delayed up to a minute or two. You can debug your code by adding print statements that will show up in the logs, and redeploying the app.
You have a complete working system. But bad actors could impersonate others, submitting bad solutions under their nicknames. That's addressed in the next, and final, step.
Anyone can connect to the app deployed in step 4, above, and submit new players under a selected nickname. In fact, someone could use a nickname already selected by another player to make lower scores appear under that name. We would like to prevent contestants impersonating others, and would like to be able to find and block bad actors if needed.
There are many ways to authenticate users, even using third parties to verify their identity, that require somewhat complicated programming to implement. Google Cloud Identity-Aware Proxy (IAP) is an easy way to achieve the same end, requiring no change in an application to restrict access to it to only selected, authenticated users. Minimal coding is needed if the application needs to know those users' identities for some purpose. This project does access users' verified identities (a unique ID number) in order to prevent impersonation and, if needed, block bad actors.
Enable IAP to restrict access to the App Engine app:
Return the tab for your app's home page (or open a new tab and enter the home page's URL) and refresh the screen. You will see a Sign in with Google prompt. Go ahead and sign in with any account.
Instead of your app's home page, you will see an error message:
You have successfully restricted access to your app. In fact, you have not specified any authorized users, so nobody, including you, can access it.
Return to the browser tab you used to enable Identity-Aware Proxy to add authorized users.
Return the tab for your app's home page (or open a new tab and enter the home page's URL) and refresh the screen. You may see a Sign in with Google prompt, or you may already be signed in from your last attempt. Go ahead and sign in with any account if needed.
Refresh the page. You should see your app's home page. It may take a few minutes for the service to update. If you still see the error message, wait a minute and refresh the page.
Open your app's home page, logging in if prompted. You should see the home page. Click the link to Request a new contest round and enter your preferred nickname and the URL of your player. Click Request round. You will be redirected to the home page and you will see your most recent request at the top of the standings. You may need to refresh the page if the request is not yet showing.
Now click the link to Request a new contest round a second time. Notice that the nickname you used before is already filled in and editing of that field has been disabled. If you know how to use your browser's developer tools, go ahead and override the disabling, fill in a different nickname (and your player's URL) and click Request round.
You will be redirected to the home page and see your most recent request listed first. Note that the new nickname you entered has been ignored, and the first one you used after logging in is shown instead.
Recall that the handler to deliver the Request a new contest round form calls the get_nickname
function and, if it returns a value, inserts that value in the form. And the handler that deals with that submitted form also calls get_nickname
and uses the returned value, regardless of what is entered in the form, if it does not return None
. Only when get_nickname
returns None
is the nickname field in the form actually used, in which case set_nickname
is called to remember that value.
Both get_nickname and set_nickname rely on a unique ID that Identity-Aware Proxy provides for the logged in user to work. When IAP is used to protect a web app, it intercepts all requests to that app and adds three HTTP headers to the request:
This app saves the user ID, matched to the first nickname submitted with it, in a Firestore collection, and then uses it to look up the nickname on future requests. The user's email could have been used instead, but we avoid storing personal information such as email address if we don't have to.
The JWT assertion is a digitally signed object that contains the email and user ID in it. The benefit of using that assertion instead of one of the other headers would be to avoid spoofing if the architecture had some way for requests to arrive from an internal location, possibly bypassing IAP. The negative of using that assertion is the extra complexity involved in validating the signature. This example is not using the JWT assertion for that reason. If you are interested in looking into this further, there is a User Authentication with Identity-Aware Proxy codelab available with full details.
The set_nickname
function checks for the existence of the IAP supplied header. If there is none, it does nothing. Otherwise it saves the nickname in a document that uses the IAP user ID value as the document ID. Note that if you call set_nickname
multiple times, it will replace any earlier value in the database:
def set_nickname(nickname):
user_id = request.headers.get('x-goog-authenticated-user-id')
if user_id is None:
return
firestore.Client().collection('nicknames').add({
'nickname': nickname
}, document_id=user_id)
The get_nickname function checks to see if there is an IAP supplied header with a unique user ID. If not, it returns None. Otherwise, it looks up the nickname that saved using the user-ID as a document ID and returns that, if found:
def get_nickname():
user_id = request.headers.get('x-goog-authenticated-user-id')
if user_id is None:
return None
nicknames = firestore.Client().collection('nicknames')
user = nicknames.document(user_id).get()
if user.exists:
return user.to_dict().get('nickname')
else:
return None