Skip to content

Commit 48dd660

Browse files
dee077nemesifier
andcommitted
[feature] Added Mass Command model and REST API #1344
Closes #1344 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent e852dde commit 48dd660

23 files changed

Lines changed: 3676 additions & 21 deletions

docs/developer/extending.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ Once you have created the models, add the following to your
354354
CONNECTION_CREDENTIALS_MODEL = "sample_connection.Credentials"
355355
CONNECTION_DEVICECONNECTION_MODEL = "sample_connection.DeviceConnection"
356356
CONNECTION_COMMAND_MODEL = "sample_connection.Command"
357+
CONNECTION_BATCHCOMMAND_MODEL = "sample_connection.BatchCommand"
357358
SUBNET_DIVISION_SUBNETDIVISIONRULE_MODEL = "sample_subnet_division.SubnetDivisionRule"
358359
SUBNET_DIVISION_SUBNETDIVISIONINDEX_MODEL = "sample_subnet_division.SubnetDivisionIndex"
359360

docs/user/intro.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ e.g.:
7070

7171
- Sending configuration updates.
7272
- :doc:`Executing shell commands <shell-commands>`.
73+
- :doc:`Executing mass commands <shell-commands>`: Run a command on
74+
multiple devices at once, see the :ref:`batch command API
75+
<controller_batch_command_api>` for details.
7376
- Perform firmware upgrades via the additional :doc:`firmware upgrade
7477
module </firmware-upgrader/index>`.
7578

docs/user/rest-api.rst

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ config of a device,
210210
211211
curl -X PATCH \
212212
http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
213-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
213+
-H 'authorization: Bearer <token>' \
214214
-H 'content-type: application/json' \
215215
-d '{
216216
"config": {
@@ -225,7 +225,7 @@ the/their {id} from the config of a device,
225225
226226
curl -X PATCH \
227227
http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
228-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
228+
-H 'authorization: Bearer <token>' \
229229
-H 'content-type: application/json' \
230230
-d '{
231231
"config": {
@@ -240,7 +240,7 @@ from the config of a device,
240240
241241
curl -X PATCH \
242242
http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/ \
243-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
243+
-H 'authorization: Bearer <token>' \
244244
-H 'cache-control: no-cache' \
245245
-H 'content-type: application/json' \
246246
-H 'postman-token: b3f6a1cc-ff13-5eba-e460-8f394e485801' \
@@ -465,7 +465,7 @@ command type being executed.
465465
466466
curl -X POST \
467467
http://127.0.0.1:8000/api/v1/controller/device/76b7d9cc-4ffd-4a43-b1b0-8f8befd1a7c0/command/ \
468-
-H 'authorization: Bearer yoursecretauthtoken' \
468+
-H 'authorization: Bearer <token>' \
469469
-H 'content-type: application/json' \
470470
-d '{
471471
"type": "custom",
@@ -481,6 +481,117 @@ Get Command Details
481481
482482
GET /api/v1/controller/device/{device_id}/command/{command_id}/
483483
484+
.. _controller_batch_command_api:
485+
486+
Dry-Run Mass Command
487+
~~~~~~~~~~~~~~~~~~~~
488+
489+
.. code-block:: text
490+
491+
GET /api/v1/controller/batch-command/execute/
492+
493+
Returns the list of devices that would be targeted without executing
494+
anything. Useful for previewing which devices are affected.
495+
496+
**Query Parameters:**
497+
498+
================ =========================================================
499+
Parameter Description
500+
================ =========================================================
501+
``organization`` Organization UUID (optional for superusers; set
502+
automatically when ``group`` or ``location`` is provided)
503+
``type`` Command type (optional for dry-run)
504+
``input`` JSON input data for the command (optional for dry-run).
505+
Encode as a URL-encoded JSON object, e.g.
506+
``?type=custom&input=%7B%22command%22%3A%22uptime%22%7D``
507+
``devices`` Repeated ``devices`` query parameter, each a device UUID
508+
(optional; when provided, ``group`` and ``location`` are
509+
ignored)
510+
``group`` Device group UUID (optional)
511+
``location`` Location UUID (optional)
512+
================ =========================================================
513+
514+
Execute a Mass Command
515+
~~~~~~~~~~~~~~~~~~~~~~
516+
517+
.. code-block:: text
518+
519+
POST /api/v1/controller/batch-command/execute/
520+
521+
Creates and executes a batch command on the targeted devices.
522+
523+
**Request Parameters:**
524+
525+
================ =========================================================
526+
Parameter Description
527+
================ =========================================================
528+
``organization`` Organization UUID (optional for superusers; set
529+
automatically when ``group`` or ``location`` is provided)
530+
``type`` Type of command to execute (**required**)
531+
``input`` Input data for the command (**conditionally required** —
532+
depends on command type)
533+
``label`` A short label to identify this batch command
534+
(**required**)
535+
``notes`` Optional notes (optional)
536+
``devices`` List of device UUIDs (optional; when provided, ``group``
537+
and ``location`` are ignored)
538+
``group`` Device group UUID (optional)
539+
``location`` Location UUID (optional)
540+
================ =========================================================
541+
542+
**Available Command Types:**
543+
544+
See :ref:`controller_execute_command_api` for available command types and
545+
input formats.
546+
547+
**Example payload:**
548+
549+
.. code-block:: json
550+
551+
{
552+
"organization": "org-uuid",
553+
"type": "custom",
554+
"input": {"command": "uptime"},
555+
"label": "Check uptime"
556+
}
557+
558+
**Example request:**
559+
560+
.. code-block:: shell
561+
562+
curl -X POST \
563+
http://127.0.0.1:8000/api/v1/controller/batch-command/execute/ \
564+
-H 'authorization: Bearer <token>' \
565+
-H 'content-type: application/json' \
566+
-d '{
567+
"organization": "org-uuid",
568+
"type": "custom",
569+
"input": {"command": "uptime"},
570+
"label": "Check uptime"
571+
}'
572+
573+
**Response:** ``201 Created`` with the batch command UUID.
574+
575+
List Mass Commands
576+
~~~~~~~~~~~~~~~~~~
577+
578+
.. code-block:: text
579+
580+
GET /api/v1/controller/batch-command/
581+
582+
Returns a paginated list of batch commands with device count and skipped
583+
device information.
584+
585+
Get Mass Command Detail
586+
~~~~~~~~~~~~~~~~~~~~~~~
587+
588+
.. code-block:: text
589+
590+
GET /api/v1/controller/batch-command/{id}/
591+
592+
Returns detailed information about a batch command, including the list of
593+
targeted devices.
594+
484595
List Device Groups
485596
~~~~~~~~~~~~~~~~~~
486597

@@ -603,7 +714,7 @@ You can create ``DeviceLocation`` object by using primary keys of existing
603714
604715
curl -X PUT \
605716
http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \
606-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
717+
-H 'authorization: Bearer <token>' \
607718
-H 'content-type: application/json' \
608719
-d '{
609720
"location": "f0cb5762-3711-4791-95b6-c2f6656249fa",
@@ -635,7 +746,7 @@ single request.
635746
636747
curl -X PUT \
637748
http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \
638-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
749+
-H 'authorization: Bearer <token>' \
639750
-H 'content-type: application/json' \
640751
-d '{
641752
"location": {
@@ -686,7 +797,7 @@ demonstrates creating both ``Location`` and ``FloorPlan`` objects.
686797
687798
curl -X PUT \
688799
http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \
689-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
800+
-H 'authorization: Bearer <token>' \
690801
-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
691802
-F 'location.name=Via del Corso' \
692803
-F 'location.address=Via del Corso, Roma, Italia' \
@@ -716,7 +827,7 @@ plan for that location using this endpoint.
716827
717828
curl -X PUT \
718829
http://127.0.0.1:8000/api/v1/controller/device/8a85cc23-bad5-4c7e-b9f4-ffe298defb5c/location/ \
719-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
830+
-H 'authorization: Bearer <token>' \
720831
-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
721832
-F location=f0cb5762-3711-4791-95b6-c2f6656249fa \
722833
-F floorplan.floor=1 \
@@ -907,7 +1018,7 @@ in a single request.
9071018
9081019
curl -X POST \
9091020
http://127.0.0.1:8000/api/v1/controller/location/ \
910-
-H 'authorization: Bearer dc8d497838d4914c9db9aad9b6ec66f6c36ff46b' \
1021+
-H 'authorization: Bearer <token>' \
9111022
-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
9121023
-F 'name=Via del Corso' \
9131024
-F 'address=Via del Corso, Roma, Italia' \

docs/user/shell-commands.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,36 @@ How to register or unregister commands
182182

183183
Refer to :ref:`registering_unregistering_commands` in the developer
184184
documentation.
185+
186+
.. _mass_commands:
187+
188+
Mass Commands
189+
-------------
190+
191+
Mass commands allow you to execute a command on multiple devices
192+
simultaneously, rather than issuing commands one device at a time. This is
193+
useful for rebooting all devices in a group, changing passwords across
194+
multiple devices, or running diagnostics on all devices in an
195+
organization.
196+
197+
**Targeting options:**
198+
199+
- ``organization``: All devices in an organization.
200+
- ``devices``: Explicit list of device UUIDs.
201+
- ``group``: Device group UUID.
202+
- ``location``: Location UUID.
203+
204+
If ``devices`` is provided, ``group`` and ``location`` are ignored.
205+
Otherwise, ``group`` and ``location`` can be used together to narrow the
206+
target set within the organization.
207+
208+
If no targeting options are provided, the command targets all devices in
209+
the organization. Superusers can omit ``organization`` to target all
210+
devices across organizations.
211+
212+
For superusers, ``organization`` is set automatically when ``group`` or
213+
``location`` is provided.
214+
215+
Refer to the :ref:`Batch Command API <controller_batch_command_api>`
216+
documentation for the available endpoints, request parameters, and
217+
examples.

openwisp_controller/connection/api/serializers.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DeviceConnection = load_model("connection", "DeviceConnection")
1313
Credentials = load_model("connection", "Credentials")
1414
Device = load_model("config", "Device")
15+
BatchCommand = load_model("connection", "BatchCommand")
1516

1617

1718
class ValidatedDeviceFieldSerializer(ValidatedModelSerializer):
@@ -43,6 +44,10 @@ class CommandSerializer(ValidatedDeviceFieldSerializer):
4344
required=False,
4445
pk_field=serializers.UUIDField(format="hex_verbose"),
4546
)
47+
batch_command = serializers.PrimaryKeyRelatedField(
48+
read_only=True,
49+
pk_field=serializers.UUIDField(format="hex_verbose"),
50+
)
4651

4752
def __init__(self, *args, **kwargs):
4853
super().__init__(*args, **kwargs)
@@ -115,3 +120,118 @@ class Meta:
115120
"is_working": {"read_only": True},
116121
}
117122
read_only_fields = ("created", "modified")
123+
124+
125+
class BatchCommandExecuteSerializer(
126+
FilterSerializerByOrgManaged, serializers.ModelSerializer
127+
):
128+
"""
129+
Parses batch command input.
130+
131+
It does not save data, so it does not need ValidatedModelSerializer.
132+
BatchCommand.execute() and dry_run() validate the model.
133+
"""
134+
135+
input = serializers.JSONField(allow_null=True, required=False)
136+
devices = serializers.PrimaryKeyRelatedField(
137+
many=True,
138+
queryset=Device.objects.all(),
139+
required=False,
140+
allow_empty=True,
141+
pk_field=serializers.UUIDField(format="hex_verbose"),
142+
)
143+
144+
def __init__(self, *args, dry_run=False, **kwargs):
145+
super().__init__(*args, **kwargs)
146+
self.dry_run = dry_run
147+
if dry_run:
148+
self.fields["type"].required = False
149+
self.fields["label"].required = False
150+
151+
class Meta:
152+
model = BatchCommand
153+
fields = (
154+
"organization",
155+
"type",
156+
"input",
157+
"label",
158+
"notes",
159+
"devices",
160+
"group",
161+
"location",
162+
)
163+
extra_kwargs = {
164+
"organization": {"required": False, "allow_null": True},
165+
}
166+
167+
def validate(self, data):
168+
org = data.get("organization")
169+
if not org and not self.context["request"].user.is_superuser:
170+
raise serializers.ValidationError(
171+
_("Only superusers can execute batch commands without an organization.")
172+
)
173+
# DRF's many=True injects [] for QueryDict even when key is
174+
# absent; remove it so model can distinguish omitted vs explicit [].
175+
if "devices" not in self.initial_data and "devices" in data:
176+
data.pop("devices")
177+
return data
178+
179+
180+
class BatchCommandSerializer(BaseSerializer):
181+
device_count = serializers.IntegerField(read_only=True)
182+
skipped_devices = serializers.JSONField(read_only=True)
183+
organization = serializers.PrimaryKeyRelatedField(
184+
read_only=True,
185+
pk_field=serializers.UUIDField(format="hex_verbose"),
186+
)
187+
group = serializers.PrimaryKeyRelatedField(
188+
read_only=True,
189+
allow_null=True,
190+
pk_field=serializers.UUIDField(format="hex_verbose"),
191+
)
192+
location = serializers.PrimaryKeyRelatedField(
193+
read_only=True,
194+
allow_null=True,
195+
pk_field=serializers.UUIDField(format="hex_verbose"),
196+
)
197+
198+
def to_representation(self, instance):
199+
data = super().to_representation(instance)
200+
# The raw password is visible in API responses between
201+
# when the batch is saved and when the Celery task runs _clean_sensitive_info().
202+
if instance.type == "change_password":
203+
data["input"] = {"password": "********"}
204+
return data
205+
206+
class Meta:
207+
model = BatchCommand
208+
fields = (
209+
"id",
210+
"organization",
211+
"status",
212+
"type",
213+
"input",
214+
"label",
215+
"notes",
216+
"group",
217+
"location",
218+
"device_count",
219+
"skipped_devices",
220+
"created",
221+
"modified",
222+
)
223+
read_only_fields = (
224+
"created",
225+
"modified",
226+
)
227+
228+
229+
class BatchCommandDetailSerializer(BatchCommandSerializer):
230+
devices = serializers.PrimaryKeyRelatedField(
231+
many=True,
232+
read_only=True,
233+
pk_field=serializers.UUIDField(format="hex_verbose"),
234+
)
235+
236+
class Meta(BatchCommandSerializer.Meta):
237+
fields = BatchCommandSerializer.Meta.fields + ("devices",)

0 commit comments

Comments
 (0)