[Beta] Open Cloud Engine API for Executing Luau

Hello Creators!

Starting today, your tools are able to headlessly run Luau scripts in a Roblox place, within the Roblox engine, via Open Cloud!

As we spoke about recently in our Vision for an Open Roblox Platform, this beta is one of the first steps in our plan to ensure external tools have access to the full power of the Roblox engine. The power of Open Cloud is now extending beyond just its own APIs and into the Luau APIs exposed by the Roblox engine and the millions of lines of Roblox Luau code creators have written.

How does it work?

Here’s how the Open Cloud Engine API for Executing Luau works:

  1. You make a request to create a task - including the universeId, placeId and the string you want to run as Luau.
  2. We launch a server, load the place, and execute the code with DataModel and Engine Luau API access.
  3. You poll for the operation status, and upon completion, receive the script’s return values and logs.

For more detail, please see our Documentation.

What do I use this for?

This Beta release allows you to run code against a place, but the changes your code makes to the place do not save. We’re working on enabling this, but for now it means this API is most useful for when you want to run a script against a place and get the values that the script returns.

One example workflow is running automated tests as part of a CI/CD pipeline for a Rojo managed project. We created a reference project to demonstrate this: GitHub: Roblox/place-ci-cd-demo.

We’re working on making this API more powerful. In the future, the API will be able to save the changes it makes to your place, and allow access to more of Roblox’s Lua APIs. Some workflows we imagine in the future include:

  • Updating pricing and game balancing (prices, stats etc.) variables outside of Studio
  • Running complex or long-running procedural generation tasks on the DataModel
  • Deploying file-based code libraries to the Creator Store, by instantiating them from serialized data and publishing them via a Lua API

Of course, we expect the most popular use cases to be things we haven’t even thought of!

Getting Started

  1. Create an API key with the new luau-execution-sessions:write scope for an experience

  2. Use our example python script below to run a luau file against the place.

    • python3 luau-execution-session.py --api-key oc-api-key.txt --universe 6346344621 --place 18730824539 --place-version 212 --script-file script.luau
  3. Await the logs and response (if you don’t want to write to stdout/stderr you can specify a destination file with the --output, --log-output arguments)

Click here to view the Python Script
import argparse
import logging
import urllib.request
import urllib.error
import base64
import sys
import json
import time
import hashlib
import os

def parseArgs():
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "-k",
        "--api-key",
        help="Path to a file containing your OpenCloud API key. You can also use the environment variable RBLX_OC_API_KEY to specify the API key. This option takes precedence over the environment variable.",
        metavar="<path to API key file>")
    parser.add_argument(
        "-u",
        "--universe",
        "-e",
        "--experience",
        required=True,
        help="ID of the experience (a.k.a. universe) containing the place you want to execute the task against.",
        metavar="<universe id>",
        type=int)
    parser.add_argument(
        "-p",
        "--place",
        required=True,
        help="ID of the place you want to execute the task against.",
        metavar="<place id>",
        type=int)
    parser.add_argument(
        "-v",
        "--place-version",
        help="Version of the place you want to execute the task against. If not given, the latest version of the place will be used.",
        metavar="<place version>",
        type=int)
    parser.add_argument(
        "-f",
        "--script-file",
        required=True,
        help="Path to a file containing your Luau script.",
        metavar="<path to Luau script file>")
    parser.add_argument(
        "-c",
        "--continuous",
        help="If specified, this script will run in a loop and automatically create a new task after the previous task has finished, but only if the script file is updated. If the script file has not been updated, this script will wait for it to be updated before submitting a new task.",
        action="store_true")
    parser.add_argument(
        "-o",
        "--output",
        help="Path to a file to write the task's output to. If not given, output is written to stdout.",
        metavar="<path to output file>")
    parser.add_argument(
        "-l",
        "--log-output",
        help="Path to a file to write the task's logs to. If not given, logs are written to stderr.",
        metavar="<path to log output file>")

    return parser.parse_args()

def makeRequest(url, headers, body=None):
    data = None
    if body is not None:
        data = body.encode('utf8')
    request = urllib.request.Request(url, data=data, headers=headers, method='GET' if body is None else 'POST')
    max_attempts = 3
    for i in range(max_attempts):
        try:
            return urllib.request.urlopen(request)
        except Exception as e:
            if 'certificate verify failed' in str(e):
                logging.error(f'{str(e)} - you may need to install python certificates, see https://stackoverflow.com/questions/27835619/urllib-and-ssl-certificate-verify-failed-error')
                sys.exit(1)
            if i == max_attempts - 1:
                raise e
            else:
                logging.info(f'Retrying error: {str(e)}')
                time.sleep(1)

def readFileExitOnFailure(path, file_description):
    try:
        with open(path, 'r') as f:
            return f.read()
    except FileNotFoundError:
        logging.error(f"{file_description.capitalize()} file not found: {path}")
    except IsADirectoryError:
        logging.error(f"Invalid {file_description} file: {path} is a directory")
    except PermissionError:
        logging.error(f"Permission denied to read {file_description} file: {path}")
    sys.exit(1)

def loadAPIKey(api_key_arg):
    source = ''
    if api_key_arg:
        api_key_arg = api_key_arg.strip()
        source = f'file {api_key_arg}'
        key = readFileExitOnFailure(api_key_arg, "API key").strip()
    else:
        if 'RBLX_OC_API_KEY' not in os.environ:
            logging.error('API key needed. Either provide the --api-key option or set the RBLX_OC_API_KEY environment variable.')
            sys.exit(1)
        source = 'environment variable RBLX_OC_API_KEY'
        key = os.environ['RBLX_OC_API_KEY'].strip()

    try:
        base64.b64decode(key, validate=True)
        return key
    except Exception as e:
        logging.error(f"API key appears invalid (not valid base64, loaded from {source}): {str(e)}")
        sys.exit(1)

def createTask(api_key, script, universe_id, place_id, place_version):
    headers = {
        'Content-Type': 'application/json',
        'x-api-key': api_key
    }
    data = {
        'script': script
    }
    url = f'https://apis.roblox.com/cloud/v2/universes/{universe_id}/places/{place_id}/'
    if place_version:
        url += f'versions/{place_version}/'
    url += 'luau-execution-session-tasks'

    try:
        response = makeRequest(url, headers=headers, body=json.dumps(data))
    except urllib.error.HTTPError as e:
        logging.error(f'Create task request failed, response body:\n{e.fp.read()}')
        sys.exit(1)

    task = json.loads(response.read())
    return task

def pollForTaskCompletion(api_key, path):
    headers = {
        'x-api-key': api_key
    }
    url = f'https://apis.roblox.com/cloud/v2/{path}'

    logging.info("Waiting for task to finish...")

    while True:
        try:
            response = makeRequest(url, headers=headers)
        except urllib.error.HTTPError as e:
            logging.error(f'Get task request failed, response body:\n{e.fp.read()}')
            sys.exit(1)

        task = json.loads(response.read())
        if task['state'] != 'PROCESSING':
            sys.stderr.write('\n')
            sys.stderr.flush()
            return task
        else:
            sys.stderr.write('.')
            sys.stderr.flush()
            time.sleep(3)

def getTaskLogs(api_key, task_path):
    headers = {
        'x-api-key': api_key
    }
    url = f'https://apis.roblox.com/cloud/v2/{task_path}/logs'

    try:
        response = makeRequest(url, headers=headers)
    except urllib.error.HTTPError as e:
        logging.error(f'Get task logs request failed, response body:\n{e.fp.read()}')
        sys.exit(1)

    logs = json.loads(response.read())
    messages = logs['luauExecutionSessionTaskLogs'][0]['messages']
    return ''.join([m + '\n' for m in messages])

def handleLogs(task, log_output_file_path, api_key):
    logs = getTaskLogs(api_key, task['path'])
    if logs:
        if log_output_file_path:
            with open(log_output_file_path, 'w') as f:
                f.write(logs)
            logging.info(f'Task logs written to {log_output_file_path}')
        else:
            logging.info(f'Task logs:\n{logs.strip()}')
    else:
        logging.info('The task did not produce any logs')

def handleSuccess(task, output_path):
    output = task['output']
    if output['results']:
        if output_path:
            with open(output_path, 'w') as f:
                f.write(json.dumps(output['results']))
            logging.info(f'Task results written to {output_path}')
        else:
            logging.info("Task output:")
            print(json.dumps(output['results']))
    else:
        logging.info('The task did not return any results')

def handleFailure(task):
    logging.error(f'Task failed, error:\n{json.dumps(task["error"])}')

if __name__ == '__main__':
    logging.basicConfig(format='[%(asctime)s] [%(name)s] [%(levelname)s]: %(message)s', level=logging.INFO)

    args = parseArgs()

    api_key = loadAPIKey(args.api_key)

    waiting_msg_printed = False
    prev_script_hash = None
    while True:
        script = readFileExitOnFailure(args.script_file, 'script')
        script_hash = hashlib.sha256(script.encode('utf8')).hexdigest()

        if prev_script_hash is not None and script_hash == prev_script_hash:
            if not waiting_msg_printed:
                logging.info("Waiting for changes to script file...")
                waiting_msg_printed = True
            time.sleep(1)
            continue

        if prev_script_hash is not None:
            logging.info("Detected change to script file, submitting new task")

        prev_script_hash = script_hash
        waiting_msg_printed = False

        task = createTask(api_key, script, args.universe, args.place, args.place_version)
        logging.info(f"Task created, path: {task['path']}")

        task = pollForTaskCompletion(api_key, task['path'])
        logging.info(f'Task is now in {task["state"]} state')

        handleLogs(task, args.log_output, api_key)
        if task['state'] == 'COMPLETE':
            handleSuccess(task, args.output)
        else:
            handleFailure(task)

        if not args.continuous:
            break

Limitations

Below are the limitations of our Beta release that we plan to lift over time:

  • Changes made to your place aren’t saved; the API runs your code on a separate server and doesn’t affect your Team Create session.

  • Access to some cloud service APIs (like DataStores, HttpService, etc.) is disabled to prevent accidental overwriting of production data.

  • Only two ten tasks can run at a time per place.

  • Each task starts a new server, so chaining Luau scripts must be done within a single Task.

  • Each task can run for up to 30 seconds.

  • Script.Source is not accessible (we know how important this is to testing workflows, so we’re actively working on a fix here)

Questions, Feedback

Please drop them below! This is a new capability and we’re excited to see what you do with it.

Thank you.

108 Likes

This topic was automatically opened after 9 minutes.

  1. What permission level does the script run at? Plugin context would be super useful.
  2. Does physics simulate while the script is running, and can the physics step APIs be used?
  3. What do you mean you’re working on enabling saving changes?? Hype??
6 Likes

What will the ratelimiting be like on this endpoint, and will any possible future saves be individually saved as a version in the history of a place?

3 Likes

Just to clarify, the concurrent task limit is per place or per game?

Also, does this require Team Create to be enabled? The statement “the API runs your code on a separate server and doesn’t affect your Team Create session” makes me think it’s the case, but it doesn’t say for sure.

7 Likes

Even though you have the new scope, I’d still like to see a toggle in Game Settings for this. I’m always weary of RCE due to the security risks.

1 Like

Is there another reason why these are disabled? Keeping these disabled simply because “developers might overwrite their production data” is problematic. We aren’t children, most developers worth their salt are smart enough to keep separate universes for production & dev environments.

Can we opt-in to allowing datastore usage? I don’t want to be waiting months because of some training wheels policy.

11 Likes

I am excited to see proper CI on Roblox is finally possible. I’d love to employ this ASAP, but

Is this limitation only for scripts parented to the datamodel, or for all scripts (including parented to nil)? I know this difference has come up before with plugin permissions where the first is problematic while the second isn’t (unless something changed and my unit test plugin has been immune).


Is this a technical limitation, or just something to prevent people from spooling up huge amount of servers, effectively creating a DoS attack?

1 Like

Can we get read only access as a starting point with data stores then?

6 Likes

I’d love to use this but no DSS access is too big of a limitation currently. We should get read access at least because a lot of games dont boot without DS access

3 Likes

Amazing update! I’m excited to get going on creating some automated testing.

Question, is there a limit to how many tasks we can create? I know it says two at a time max. As long as we wait for those to end, can we spawn up more of those after that?

2 Likes

While I understand there needs to be limits on how long operations can run for & how many of such operations can be running in a place at a time, this is pretty problematic for my team’s usecase.

Currently, our game has a pretty significant problem with the way it stores player save data in the datastores. Because the game (and by extension its data system) were created years before datastore V2 APIs came out, we had to implement our own key versioning. We accomplish this with a unique datastore structure.

We have two datastores that deal with player data : Production_PlayerData1_Data and Production_PlayerData1_DataPointers. Both datastores have a unique scope for each unique player (their user ID).

Production_PlayerData1_DataPointers is an ordered datastore that has “pointer” keys that point to a corresponding savedata key in the non-ordered datastore.

Production_PlayerData1_DataPointers:

Key Name Value
02/05/20 07:19:24 4
02/04/20 16:40:43 3
02/03/20 19:15:18 2
02/03/20 18:30:15 1

Production_PlayerData1_Data:

Key Name Value
4 {“Ranking”:{“Level”:10,“EXP”:180},“Weapons”:[16,28,97,212],“Currency”:{“Gold”:305,“Gems”:12},“_FormatVersion”:2}
3 {“Ranking”:{“Level”:10,“EXP”:180},“Weapons”:[12,16,28,97,212,80],“Currency”:{“Gold”:235,“Gems”:8},“_FormatVersion”:2}
2 {“Coins”:160,“Weapons”:[12,16,28,97],“Level”:6,“_FormatVersion”:1}
1 {“Coins”:20,“Weapons”:[12,16,28],“Level”:4,“_FormatVersion”:1}

Every time a player joins the game, a new numbered key is created for them. This allowed us to have data versioning (in the event we needed a rollback) before V2 APIs existed. This, obviously, has a huge problem - it uses a huge amount of storage and bandwidth at-scale.

We want to use V2 APIs, but to do this we would need to bulk-migrate all of our player’s existing save keys to a new data structure in the datastores using V2 APIs (single key, metadata is used, etc). We can’t do this in game servers at runtime when a player joins, because that would be 4+ datastore requests per player join if we migrated their data. The only way we can feasibly do this without introducing a ton of complications to our game is to simply take our game down for maintenance for a few hours and run a bulk migration using this new beta feature. However, because of the 30 second per task limit, this would not work.

We’re essentially stuck hogging data and and bandwidth ad infinitum until we can do this bulk data operation. Doing this via opencloud datastore keys would not work either, as the external http limit is naturally going to be lower than an internal datastore API limit running on roblox servers.

2 Likes

Just to clarify, the concurrent task limit is per place or per game?

It is per place_id not per experience (aka game).

Also, does this require Team Create to be enabled? The statement “the API runs your code on a separate server and doesn’t affect your Team Create session” makes me think it’s the case, but it doesn’t say for sure.

It works with or without Team Create being enabled. (in another word, does not require Team Create).

7 Likes

Thanks for your questions!

Today it is GameScript.

Running as a plugin is challenging because this is not technically Studio, it’s a Roblox server. Our goal is to identify the relevant APIs and make sure they are supported in Luau execution too, starting with script.Source. Please let us know what else in Plugin context is useful!

Not currently - though we are thinking about how we could give more control over this. Please let us know more about what you’d use this for!

Our long term goal is to let you edit your place via luau commands run by open cloud. This is a key component to connecting the power of the engine with the power of open cloud. As I’m sure you can imagine, there’s a lot of work needed to make this work properly. We’ll keep you all posted as things develop here.

9 Likes

Today, all scripts. The technical reason - is script.Source is PluginSecurity, and roblox game servers run code at GameScript. We know we need to support script.Source, and are actively working on a follow up to enable this.

6 Likes

Yeah we don’t need baby proofing :joy:

I think it’s fine for now though, for now if it’s for Testing we can use MockDatastore and create fake data :muscle:

There is no limit on the total number of tasks over time, only the number of active tasks. Once a previous task completes, you can create a new one.

5 Likes

Does this have anything to do with universe/game-level scripting? What’s the status on that, is it still on the roadmap?

Not “as a plugin” - with Plugin permissions. Thread identity 6. I know RCCService isn’t the same thing as Studio, but there are engine APIs that can’t be used from normal game scripts (such as … for instance … the StepPhysics API) and it would become so useful to be able to use them here.

I don’t expect plugin to exist or anything, or any of the Studio-specific Services, just for normal engine functions that just-so-happen to be PluginSecurity to become accessible.

2 Likes

That limitation makes perfect sense. It does have 1 side-effect: CI/CD for plugin-related code becomes trickier. It is perfectly solvable with mocking and adapters; it just introduces friction for someone trying to use the feature and not realizing the limitation.

2 Likes