Quick links
Tutorial-Appgithub repository- Task API documentation
- Bootstrapping
- Implementation
- Bundling
- Testing
- Publishing
This tutorial will guide you through the process of creating a sample Golem application. We will work with the Golem-Task-Api library to quickly bootstrap and test our app.
Note: the
Golem-Task-Apihelper library is currently written in Python (3.6) and serves as a foundation for apps built in that language. The Rust and static binary versions of that library are currently on the roadmap.
The Tutorial-App is a simple Proof of Work application. Golem tasks are initialized with a PoW difficulty parameter, set by the application user (requestor). After publishing the task in the network, Golem finds providers that are willing to participate in the computation. Each provider then computes a PoW with the set input difficulty and for different chunks of input data. The results will be sent back to the requestor and verified. For a more detailed description of task's lifecycle in the network, please refer to this section.
The github repository for the Tutorial-App can be found here.
The guide for creating Tutorial-App will cover the following aspects of Task API app development:
- Bootstrapping the application with the
Golem-Task-Apilibrary. - Implementing requestor-side logic:
- creating a new task
- splitting a task into parts
- creating subtasks for each of the parts
- verifying subtask computation results
- Implementing provider-side logic:
- computing subtasks
- benchmarking the application
- Bundling the application inside a Docker image.
- Testing.
- Publishing the application.
The Task API library provides an out-of-the-box gRPC server compliant with the API specification. The messages used for the API calls are specified in the Protocol Buffers format. The Task API library hides these details from developers and provides a convenient way to implement application's logic.
The protocol and message definition files can be found here.
Currently, the Task API apps are required to be built as Docker images. This section provides more details on the subject.
For the purposes of developing the tutorial app you will need:
- Python 3.6
- Docker
Golem docs will guide you through installing these tools for your operating system.
This tutorial assumes that you possess the knowledge of:
- Python 3.6+ language and features
asyncioasynchronous programming module- basic Docker usage
On your disk, create the following directory structure:
Tutorial-App
├── image/
│ └── tutorial_app/
└── tests/
imageis the main project directory, containing the application code and the Docker image definitionimage/tutorial_appis the root directory for the app's Python moduletestsis the place for thepytesttest suite
Let's switch to Tutorial-App/image/tutorial_app as our main working directory. To define the Python package, create the following:
Tutorial-App
└── image/
└── tutorial_app/
├── tutorial_app/
├── setup.py
└── requirements.txttutorial_appwill contain the Python module source codesetup.pyis thesetuptoolsproject definition filerequirements.txtdefines project's requirements used bypip
This file is composed of 2 parts:
- parsing the
requirements.txtfile - calling the
setuptools.setupfunction
The parse_requirements function converts the contents of a pip requirements file to a format understood by the setup function. This eliminates a need to double the work by specifying the requirements manually. parse_requirements is implemented as follows:
def parse_requirements(file_name: str = 'requirements.txt') -> List[str]:
file_path = Path(__file__).parent / file_name
with open(file_path, 'r') as requirements_file:
return [
line for line in requirements_file
if line and not line.strip().startswith(('-', '#'))
]Now, to call the setup function:
setup(
name='Tutorial-App',
version=VERSION, # defined in constants.py
packages=['tutorial_app'],
python_requires='>=3.6',
install_requires=parse_requirements(),
)Our requirements file will use an extra PIP package repository to fetch the golem_task_api package.
--extra-index-url https://builds.golem.network
# peewee ORM for database-backed persistence
peewee==3.11.2
golem_task_api==0.24.1
dataclasses==0.6; python_version < '3.7'
async_generator; python_version < '3.7'Note that the project also requires two features backported from Python 3.7 - dataclasses and async_generator.
Now, let's define our Python module.
Tutorial-App
└── image/
└── tutorial_app/
└── tutorial_app/
├── __init__.py
└── entrypoint.py
__init__.pyis an empty module init fileentrypoint.pywill start the application server
The code in entrypoint.py source file is responsible for starting the gRPC server and executing requestor or provider logic. All server initialization code and message handling is done by the Golem-Task-Api library (here referred to by api).
The role that the server will be executed in is determined by command line arguments:
python entrypoint.py requestor [port]starts the requestor app server on the specified portpython entrypoint.py provider [port]analogically, starts the provider app server on the specified port
The initialization logic itself is defined in the main function:
async def main():
await api.entrypoint(
work_dir=Path(f'/{api.constants.WORK_DIR}'),
argv=sys.argv[1:],
requestor_handler=RequestorHandler(),
provider_handler=ProviderHandler(),
)work_diris the application's working directory, as seen from inside the Docker container. A real file system location is mounted on that directoryargvis a list / tuple of command line argumentsrequestor_handleris an instance of requestor's app logic handlerprovider_handleris an instance of provider's app logic handler
The entrypoint is run directly via the __main__ execution entry point. The following code initializes the asyncio event loop and executes the main function within it:
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())The actual logic for RequestorHandler and ProviderHandler will be defined elsewhere as these classes are here to satisfy the implementation of the interfaces. By writing our code this way, we isolate the implementation from the bootstrapping code.
The RequestorHandler is derived from the Task API's RequestorAppHandler and defined as follows:
class RequestorHandler(api.RequestorAppHandler):
async def create_task(
self,
task_work_dir: RequestorTaskDir,
max_subtasks_count: int,
task_params: dict,
) -> Task:
return await create_task(task_work_dir, max_subtasks_count, task_params)
async def next_subtask(
self,
task_work_dir: RequestorTaskDir,
subtask_id: str,
opaque_node_id: str,
) -> Optional[Subtask]:
return await next_subtask(task_work_dir, subtask_id)
async def verify(
self,
task_work_dir: RequestorTaskDir,
subtask_id: str,
) -> Tuple[VerifyResult, Optional[str]]:
return await verify_subtask(task_work_dir, subtask_id)
async def discard_subtasks(
self,
task_work_dir: RequestorTaskDir,
subtask_ids: List[str],
) -> List[str]:
return await discard_subtasks(task_work_dir, subtask_ids)
async def has_pending_subtasks(
self,
task_work_dir: RequestorTaskDir,
) -> bool:
return await has_pending_subtasks(task_work_dir)
async def run_benchmark(
self,
task_work_dir: RequestorTaskDir,
) -> float:
return await run_benchmark()
async def abort_task(
self,
task_work_dir: RequestorTaskDir,
) -> None:
return await abort_task(task_work_dir)
async def abort_subtask(
self,
task_work_dir: RequestorTaskDir,
subtask_id: str
) -> None:
return await abort_subtask(task_work_dir, subtask_id)Next, the ProviderHandler (Task API's ProviderAppHandler [LINK]):
class ProviderHandler(api.ProviderAppHandler):
async def compute(
self,
task_work_dir: ProviderTaskDir,
subtask_id: str,
subtask_params: dict,
) -> Path:
return await compute_subtask(task_work_dir, subtask_id, subtask_params)
async def run_benchmark(
self,
task_work_dir: Path,
) -> float:
return await run_benchmark()We will go back to actual command implementations in the next sections. For now, let's focus on the core logic of the application - Proof of Work.
Tutorial-App
└── image/
└── tutorial_app/
└── tutorial_app/
├── __init__.py
├── entrypoint.py
└── proof_of_work.py
According to Wikipedia:
A Proof-of-Work (PoW) system (or protocol, or function) is a consensus mechanism. It allows to deter denial of service attacks and other service abuses such as spam on a network by requiring some work from the service requester, usually meaning processing time by a computer.
The PoW used in the Tutorial-App is literal - providers execute a computationally expensive function, whose result can be easily verified by the requestor.
To implement a PoW system we need at least 2 functions - compute and verify. With PoW computation, we try to find a digest that satisfies a certain difficulty threshold. We define the compute function as follows:
_MAX_NONCE: int = 2 ** 256
def compute(
input_data: str,
difficulty: int,
) -> Tuple[str, int]:
target = 2 ** (256 - difficulty)
for nonce in range(_MAX_NONCE):
if api.threading.Executor.is_shutting_down():
raise RuntimeError("Interrupted")
hash_result = _sha256(input_data + str(nonce))
if int(hash_result, 16) < target:
return hash_result, nonce
raise RuntimeError("Solution not found")In order not to block the event loop thread, we're going to execute compute in a dedicated thread. Executor.is_shutting_down will signal whether we should stop the iteration, since the result is going to be discarded anyway.
For verification purposes, a hash is computed from the same input_data and compared with the received hash. Then, we perform a sanity check on the hash difficulty:
def verify(
input_data: str,
difficulty: int,
against_result: str,
against_nonce: int,
) -> None:
target = 2 ** (256 - difficulty)
result = _sha256(input_data + str(against_nonce))
if against_result != result:
raise ValueError(f"Invalid result hash: {against_result} != {result}")
if int(result, 16) >= target:
raise ValueError(f"Invalid result hash difficulty")To satisfy the Task API interface, we need to provide a benchmarking function. It will measure the execution time of an arbitrary number of iterations. The result will be converted to a fixed score range.
def benchmark(
iterations: int = 2 ** 8,
) -> float:
started = time.time()
for nonce in range(iterations):
if api.threading.Executor.is_shutting_down():
raise RuntimeError("Interrupted")
hash_result = _sha256('benchmark' + str(nonce))
elapsed = time.time() - started
if elapsed:
return 1000. / elapsed
return 1000.Here is the remaining _sha256 function, which converts the input data to bytes and returns a hex-encoded digest of that data:
def _sha256(input_data: str) -> str:
input_bytes = input_data.encode('utf-8')
return hashlib.sha256(input_bytes).hexdigest()Tutorial-App
└── image/
└── tutorial_app/
└── tutorial_app/
├── __init__.py
├── entrypoint.py
├── proof_of_work.py
└── constants.py
This file contains the Docker image name and the application version.
DOCKER_IMAGE = 'golemfactory/tutorialapp'
VERSION = '1.0.0'Tutorial-App
└── image/
└── tutorial_app/
└── tutorial_app/
├── __init__.py
├── entrypoint.py
├── proof_of_work.py
└── task_manager.py
The Task API library is equipped with an out-of-the-box task manager. It is based on the following concepts:
-
part
An outcome of splitting a task into separate units of work. There usually exists a constant number of parts.
-
subtask
A clone of a chosen task part that will be assigned to a computing node.
Each subtask is given a unique identifier in order to distinguish computation attempts of the same part, which may fail due to unexpected errors or simply time out.
A successful subtask computation concludes the computation of the corresponding part.
The included task manager will help you in the following areas:
- splitting the task into parts
- creating and managing subtasks bound to task parts
- updating the status of each subtask
- persisting the state to disk
The default TaskManager class is built on the peewee library and uses an SQLite database by default.
SubtaskStatus is defined in golem_task_api.apputils.task as:
class SubtaskStatus(enum.Enum):
WAITING = None
COMPUTING = 'computing'
VERIFYING = 'verifying'
SUCCESS = 'success'
FAILURE = 'failure'
ABORTED = 'aborted'For the purpose of this project, we're going to extend the task part model with additional fields.
from golem_task_api.apputils.task import database
class Part(database.Part):
input_data = peewee.CharField(null=True)
difficulty = peewee.FloatField(null=True)
class TaskManager(database.DBTaskManager):
def __init__(self, work_dir: Path) -> None:
super().__init__(work_dir, part_model=Part)We've added two fields to the Part model:
input_data, which will be the source data for our PoW compute functiondifficulty, representing the PoW function difficulty threshold
Now we want the DBTaskManager to use our Part model. We can do that by calling super().__init__(work_dir, part_model=Part).
Tutorial-App
└── image/
└── tutorial_app/
└── tutorial_app/
├── __init__.py
├── entrypoint.py
├── proof_of_work.py
├── task.py
└── commands.py
In the tutorial app, we're going to assume that each resource (be it subtask input data or subtask computation result) is a ZIP file containing a single file. To simplify read operations on files inside of those archives, we will define the following helper function:
def _read_zip_contents(path: Path) -> str:
with zipfile.ZipFile(path, 'r') as f: # open the archive
input_file = f.namelist()[0] # assume there's a single file inside
with f.open(input_file) as zf: # open the archived file
return zf.read().decode('utf-8') # read the contents as a stringLet's go through the commands, one by one.
-
create_taskReturns a computation environment ID and prerequisites JSON, specifying the parameters needed to execute task computation.
async def create_task( work_dir: RequestorTaskDir, max_part_count: int, task_params: dict, ) -> Task: # validate the 'difficulty' parameter difficulty = int(task_params['difficulty']) if difficulty < 0: raise ValueError(f"difficulty={difficulty}") # check whether resources were provided resources = task_params.get('resources') if not resources: raise ValueError(f"resources={resources}") # read the input resource file, provided by the user (requestor) try: task_input_file = work_dir.task_inputs_dir / resources[0] input_data = task_input_file.read_text('utf-8') except (IOError, StopIteration) as exc: raise ValueError(f"Invalid resource file: {resources} ({exc})") # create the task within the task manager task_manager = TaskManager(work_dir) task_manager.create_task(max_part_count) # update the parts in the database with our input data for num in range(max_part_count): part = task_manager.get_part(num) part.input_data = input_data + str(uuid.uuid4()) part.difficulty = difficulty + (difficulty % 2) part.save() # return task's definition. We need to specify: # - the execution environment ID. Here: Docker CPU-only compute # - specify the Docker CPU environment prerequisites # - a minimum memory requirement in MiB return Task( env_id=DOCKER_CPU_ENV_ID, # 'docker_cpu' prerequisites=PREREQUISITES, inf_requirements=Infrastructure(min_memory_mib=50.))
Where
PREREQUISITEStell us which Docker image providers should pull and execute:PREREQUISITES = { "image": 'golemfactory/tutorialapp', "tag": "1.0", }
-
abort_taskWill be called when the subtask is aborted by the user or timed out. Should stop verification of the subtask (if it's running) and perform any other necessary cleanup.
async def abort_task( work_dir: RequestorTaskDir, ) -> None: task_manager = TaskManager(work_dir) # abort the task and currently running subtasks task_manager.abort_task()
-
abort_subtaskWill be called when the subtask is aborted by the user or timed out.
async def abort_subtask( work_dir: RequestorTaskDir, subtask_id: str ) -> None: task_manager = TaskManager(work_dir) # abort a single subtask by changing its status to 'ABORTED'; # task manager will automatically handle the new status task_manager.update_subtask_status(subtask_id, SubtaskStatus.ABORTED)
-
next_subtaskReturns subtask_params_json which is the JSON string containing subtask specific parameters. Also returns resources which is a list of names of files required for computing the subtask. Files with these names are required to be present in
{task_id}/{constants.SUBTASK_INPUTS_DIR}directory.Can return an empty message meaning that the app refuses to assign a subtask to the provider node (for whatever reason).
async def next_subtask( work_dir: RequestorTaskDir, subtask_id: str, ) -> Optional[api.structs.Subtask]: task_manager = TaskManager(work_dir) # check whether we have any parts left for computation part_num = task_manager.get_next_computable_part_num() if part_num is None: return None # get the part model from the database part = task_manager.get_part(part_num) # write subtask input data file under a predefined directory subtask_input_file = work_dir.subtask_inputs_dir / f'{subtask_id}.zip' with zipfile.ZipFile(subtask_input_file, 'w') as zf: zf.writestr(subtask_id, part.input_data) resources = [subtask_input_file.name] # bind the subtask to the part number and mark it as started task_manager.start_subtask(part_num, subtask_id) # create subtask's definition. We need to specify: # - subtask parameters, which will be passed to providers as input # computation parameters # - a list of resources (file names) for providers to download and # use for the computation. Resource transfers are handled # automatically by Golem return api.structs.Subtask( params={ 'difficulty': part.difficulty, 'resources': resources, }, resources=resources, )
-
verify_subtaskCalled when computation results have been downloaded by Golem. For successfully verified subtasks it can also perform merging of the partial results into the final one.
async def verify_subtask( work_dir: RequestorTaskDir, subtask_id: str, ) -> Tuple[VerifyResult, Optional[str]]: subtask_outputs_dir = work_dir.subtask_outputs_dir(subtask_id) # read contents of the subtask input data file output_data = _read_zip_contents(subtask_outputs_dir / f'{subtask_id}.zip') # parse the read data as PoW computation result and nonce provider_result, provider_nonce = output_data.rsplit(' ', maxsplit=1) provider_nonce = int(provider_nonce) # notify the task manager that the subtask is being verified task_manager = TaskManager(work_dir) task_manager.update_subtask_status(subtask_id, SubtaskStatus.VERIFYING) try: # retrieve current part model from the database part_num = task_manager.get_part_num(subtask_id) part = task_manager.get_part(part_num) # execute the verification function proof_of_work.verify( part.input_data, difficulty=part.difficulty, against_result=provider_result, against_nonce=provider_nonce) except (AttributeError, ValueError) as err: # verification has failed; update the status in the task manager task_manager.update_subtask_status(subtask_id, SubtaskStatus.FAILURE) return VerifyResult.FAILURE, err.message # verification has succeeded # copy results to the output directory, set by the requestor shutil.copy( subtask_outputs_dir / f'{subtask_id}.zip', work_dir.task_outputs_dir / f'{subtask_id}.zip') # update the status in the task manager task_manager.update_subtask_status(subtask_id, SubtaskStatus.SUCCESS) return VerifyResult.SUCCESS, None
-
discard_subtasksShould discard results of given subtasks and any dependent subtasks.
async def discard_subtasks( work_dir: RequestorTaskDir, subtask_ids: List[str], ) -> List[str]: task_manager = TaskManager(work_dir) # the PoW app simply aborts the subtasks in this case for subtask_id in subtask_ids: task_manager.update_subtask_status(subtask_id, SubtaskStatus.ABORTED) return subtask_ids
-
has_pending_subtasksReturns a boolean indicating whether there are any more pending subtasks waiting for computation at given moment.
async def has_pending_subtasks( work_dir: RequestorTaskDir, ) -> bool: task_manager = TaskManager(work_dir) return task_manager.get_next_computable_part_num() is not None
-
run_benchmarkReturns a score which indicates how efficient the machine is for this type of tasks.
async def run_benchmark() -> float: # execute the benchmark function in background and wait for the result return await api.threading.Executor.run(proof_of_work.benchmark)
-
compute_subtaskExecutes the computation.
async def compute_subtask( work_dir: ProviderTaskDir, subtask_id: str, subtask_params: dict, ) -> Path: # validate subtask input parameters resources = subtask_params['resources'] if not resources: raise ValueError(f"resources={resources}") difficulty = int(subtask_params['difficulty']) if difficulty < 0: raise ValueError(f"difficulty={difficulty}") # read the subtask input data from file, saved in a predefined directory subtask_input_file = work_dir.subtask_inputs_dir / resources[0] subtask_input = _read_zip_contents(subtask_input_file) # execute computation in background and wait for the result hash_result, nonce = await api.threading.Executor.run( proof_of_work.compute, input_data=subtask_input, difficulty=difficulty) # bundle computation output and save it in a predefined directory subtask_output_file = work_dir / f'{subtask_id}.zip' with zipfile.ZipFile(subtask_output_file, 'w') as zf: zf.writestr(subtask_id, f'{hash_result} {nonce}') # return the name of our output file return subtask_output_file.name
Tutorial-App
└── image/
├── tutorial_app/
└── Dockerfile
In this section we're going to build a Docker image with our application. Please create an empty Dockerfile in the Tutorial-App/image/ directory. Then, add the following lines:
-
The image is going to be derived from a base Golem image.
FROM golemfactory/base:1.5 -
Install prerequisites.
RUN apt update RUN apt install -y python3-pip
-
Copy application code.
COPY tutorial_app /golem/tutorial_app -
Install required packages and the app itself.
RUN python3 -m pip install --no-cache-dir --upgrade pip RUN python3 -m pip install --no-cache-dir -r /golem/tutorial_app/requirements.txt RUN python3 -m pip install --no-cache-dir /golem/tutorial_app
-
Clean up the no longer needed packages.
RUN apt remove -y python3-pip RUN apt clean
-
Set up the working directory inside the image and the entrypoint.
WORKDIR /golem/work ENTRYPOINT ["python3", "-m", "tutorial_app.entrypoint"]
In order to build the image, execute the following command in the image directory:
docker build -t tutorial_app -f Dockerfile .That's it!
TBD
In this section we are going to run your application locally inside golem. Before we start make sure you have the prerequisites:
- Prepared docker image.
golemfactory/tutorialapp:1.0.0in this example - Working golem node from source
If you have followed this tutorial so far you should have a build docker image.
$ docker image golemfactory/tutorialapp
REPOSITORY TAG IMAGE ID CREATED SIZE
golemfactory/tutorialapp 1.0.0 <random_id> <some time ago> <not so large>When you do not have this build yet go to the previous step of this guide.
The easiest way to test your app is to run a basic_integration test, provided in the golem source files.
In this example we will run the test on the tutorialapp.
The required input files are available in the examples on github. Put both source folders next to each other to copy/paste the commands below.
../
├── tutorialapp/
└── golem/
- Navigate to the golem source folder and enable your virtual env ( optional ), make sure golem is not running.
- Enable developer mode
TODO: add to
app_cfg.iniso it is easy to enable workaround: comment outgolem/envs/docker/cpu.py:589-592
client.pull(
prerequisites.image,
tag=prerequisites.tag
)
# becomes =>
#client.pull(
# prerequisites.image,
# tag=prerequisites.tag
#)Now you are ready to test the app! Run the following commands:
pip install -r requirements.txt
python scripts/task_api_tests/basic_integration.py task-from-app-def ../tutorialapp/examples/app_descriptor.json ../tutorialapp/examples/app_parameters.json --resources ../tutorialapp/examples/input.txt --max-subtasks=1To run the tutorial app between your own nodes:
- Add the app descriptor JSON file to your requestor's data-dir
- Build the docker image on all nodes
- Whitelist the image repository on both requestor and provider side. TODO: add task-api-dev config to not pull images and whitelist local images
- create a
task.jsonfile to request a task - Enter a private network ( optional, recommended to not have others pick up your task )
- Create a task using the
golemcli
- The app descriptor file is needed by requesting nodes and should be placed under
<golem_data_dir>/appsdirectory.golem_data_dircan be found at the following locations:
-
macOS
~/Library/Application Support/golem/default/<mainnet|rinkeby> -
Linux
~/.local/share/golem/default/<mainnet|rinkeby> -
Windows
%LOCALAPPDATA%\golem\golem\default\<mainnet|rinkeby>1b. The first time you start golem the app is enabled by default, the second time you need to update the database to enable it ( TODO: make easier ) Update yourgolem.dbin the tableappconfigapp with ID801964d531675a235c9ddcfda00075cbshould get enabled = 1
- check if you have [#prepare-docker-image|prepared the docker image] on all participating nodes.
- check if you have [#enable-developer-mode|enbled developer mode] on all participating nodes.
3b. when using a image name not starting with
golemfactory/you need to [#whitelist-repository|whitelist the repository]. - Create a
task.jsonfile with the following contents, make sure to update the paths to your setup:
{
"golem": {
"app_id": "801964d531675a235c9ddcfda00075cb",
"name": "test task",
"resources": ["/absolute/path/to/input.txt"],
"max_price_per_hour": "1000000000000000000",
"max_subtasks": 4,
"task_timeout": 180,
"subtask_timeout": 60,
"output_directory": "/absolute/path/to/output/directory"
},
"app": {
"difficulty": "1",
"resources": [
"tutorial-input.txt"
]
}
}- (optional) Enter a private network by starting all golem nodes with the
protocol_idoption
golemapp --protocol_id=1337- Now you can can start the task using golemcli:
golemcli tasks create ./task.jsonIn order to make your app available to others, you will need to:
-
Upload the app image to Docker Hub
This short tutorial will guide you through the process.
-
Create an app descriptor file and make it publicly available. The file has the following format:
{ "name": "repository/image_name", "description": "My app", "version": "1.0.0", "author": "Me <me@company.org>, Others <others@company.org>", "license": "GPLv3", "requestor_env": "docker_cpu", "requestor_prereq": { "image": "repository/image_name", "tag": "1.0.0" }, "market_strategy": "brass", "max_benchmark_score": 10000.0 }The app descriptor file is needed by requesting nodes and should be placed under
<golem_data_dir>/appsdirectory.golem_data_dircan be found at the following locations:-
macOS
~/Library/Application Support/golem/default/<mainnet|rinkeby> -
Linux
~/.local/share/golem/default/<mainnet|rinkeby> -
Windows
%LOCALAPPDATA%\golem\golem\default\<mainnet|rinkeby>
-
-
Have both requestors and providers whitelist your image repository within Golem.
In order to manage a repository whitelist use the following CLI commands:
golemcli debug rpc env.docker.repos.whitelist
To display all whitelisted repositories.
golemcli debug rpc env.docker.repos.whitelist.add <repository_name>
Whitelist
<repository_name>.golemcli debug rpc env.docker.repos.whitelist.remove <repository_name>
Remove the
<repository_name>from your whitelist.golemcli debug rpc env.docker.images.discovered
List all app images seen by your node on the network.