Operating with the cuttle CLI

How to operate with the cuttle CLI.

The SPEKTRA Edge controller consists of multiple services, and the cuttle command also consists of corresponding subcommands. For example, the subcommands cuttle devices, cuttle limits, cuttle iam, and cuttle monitoring directly correspond to the devices, iam, limits, and monitoring services. Default cuttle offers access to core SPEKTRA Edge services.

For specialized ones, built-on top of SPEKTRA Edge (like watchdog), cuttle is slightly different: cuttle-watchdog v1alpha2 <subcommand> <collection> .... Note that this specialized cuttle requires API version to be provided as first argument. Regular cuttle as of now does offer only the newest (v1) version.

Almost all resources related to SPEKTRA Edge support the Create, Read, Update, and Delete (CRUD) operations. cuttle supports the create, get, batch-get, list, watch, update, and delete subcommands, respectively.

Usually, after specifying service and command, you need to specify resource type.

As an example, if you want to list all device resources on the devices service in a project, run the command cuttle devices list devices --project $PROJECT. Similarly, to retrieve the Role Binding resource named projects/test/role-bindings/rb01 on the IAM service, execute the command cuttle iam get role-binding projects/test/role-bindings/rb01.

Apart from standard CRUD, cuttle exposes custom API calls as well, like cuttle devices ssh <deviceName>. To see custom commands in a service, you can invoke cuttle <service> --help.

Cuttle provides operations output using table or JSON format, table is the default. To see a response in JSON, add -o json to arguments when invoking commands. JSON is able to display structures more properly in many cases.

You can add prettifier to the cuttle output if you use json formatting using | jq . like:

$ cuttle iam list devices --project $PROJECT -o json | jq .

Refer to API manuals of what you can do on EdgeLQ. Cuttle CLI supports all unary and server-streaming commands.

Write operations

Standard write operations are create, update and delete. Note that create operations allow multiple syntaxes when specifying resource name.

# Create a device resource with specified ID and parent name (containing
# project and region)
$ cuttle devices create device dev-id-1 --parent projects/your-project/regions/us-west2 \
  <FIELD-ARGS> -o json
  
# Create a device resource with a bit different syntax than before.
$ cuttle devices create device dev-id-2 --project your-project --region us-west2 \
  <FIELD-ARGS> -o json

# Create a device with a RANDOM ID (since we do not specify ID of a device).
# This command naturally can be invoked with --project and --region too.
$ cuttle devices create device --parent projects/your-project/regions/us-west2 \
  <FIELD-ARGS> -o json

# Update a device
$ cuttle devices update device projects/your-project/regions/us-west2/devices/dev-id-1 \
  <FIELD-ARGS> <UPDATE-MASK-ARGS> -o json

# Delete a device (no output is provided if no error happens)
$ cuttle devices delete device projects/your-project/regions/us-west2/devices/dev-id-1

Resources usually belong to a project (like resource Distribution in applications.edgelq.com), or project with region (like resource Device in devices.edgelq.com). Occasionally some resources have more parent segments:

  • monitoring.edgelq.com/AlertingCondition has parent projects/{project}/regions/{region}/alertingPolicies/{alertingPolicy}.
  • monitoring.edgelq.com/Alert has parent projects/{project}/regions/{region}/alertingPolicies/{alertingPolicy}/alertingConditions/{alertingCondition}.
  • iam.edgelq.com/ServiceAccountKey has parent projects/{project}/regions/{region}/serviceAccounts/{serviceAccount}.

Some resources may have multiple parent types (but specific instance can have only one). For example, resource iam.edgelq.com/RoleBinding has following parent name patterns:

  • projects/{project}: Specifies RoleBinding in a Project scope.
  • organizations/{organization}: Specifies RoleBinding in a Organization scope.
  • services/{service}: Specifies RoleBinding in a Service scope.
  • ``: Specifies RoleBinding in a system (root) scope (they have internal purpose).
$ cuttle iam create role-binding rb-id --parent 'projects/your-project' -o json
$ cuttle iam create role-binding rb-id --parent 'organizations/your-org' -o json
$ cuttle iam create role-binding rb-id --parent 'services/your-service' -o json
$ cuttle iam create role-binding rb-id # In a system scope -o json

Refer to a resource documentation to check possible name patterns.

Resource name serves as an identifier and cannot be changed.

Field arguments

Create/Update operations require typically providing fields for a resource. You need to take a look at a specific resource specification to know list of fields. For example, here you can find specification of monitoring.edgelq.com/AlertingCondition.

Field names must be specified using --kebab-case format, like --display-name here.

$ cuttle monitoring create alerting-condition cnd-id --parent '...' \
  --display-name 'VALUE HERE' <MORE-FIELD-ARGS-OPTIONALLY> -o json

Note that you can specify only top fields from a resource. In order to specify a field that contains an object, you must pass JSON string (quoted):

$ cuttle monitoring create alerting-condition cnd-id --parent '...' \
  --spec '{"timeSeries":{\
    "query":{\
      "filter": "metric.type=\"devices.edgelq.com/device/cpu/utilization\" AND resource.type=\"devices.edgelq.com/device\"",\
      "aggregation": {"alignmentPeriod":"300s", "perSeriesAligner":"ALIGN_SUMMARY","crossSeriesReducer":"REDUCE_MEAN","groupByFields":["resource.labels.device_id"]}\
    },\
    "threshold":{"compare":"GT", "value":0.9},\
    "duration":"900s"\
  }}' <MORE-FIELD-ARGS-OPTIONALLY> -o json

Inside object, all field names must use lowerCamelCase.

Other top field types (than strings and objects) are:

  • booleans (true/false), no quoting needed
  • numbers (integers or floats), no quoting needed
  • enums - they work like strings
  • durations - you need to pass an string with s. For example 300s is a Duration of 300 seconds.
  • timestamps - format is YYYY-MM-DDTHH:MM:SS.xxxxxxxxxZ (you can omit sub-seconds though).

Occasionally, you may need to set an array field. For example, there is a field enabled-services in a iam.edgelq.com/Project resource. Suppose you want to create a project with 2 services enabled:

$ cuttle iam create project $PROJECT_ID --title $TITLE \
  --enabled-services 'services/watchdog.edgelq.com' \
  --enabled-services 'services/ztna.edgelq.com'

Update mask arguments

When updating (using update command) a resource using the cuttle command, be careful about setting unintended zero values.

The update command defines only the top-level fields as arguments, and sets the lower-level fields as JSON objects in the value. To update only specific fields in the JSON object and ignore omitted fields, you must specify an Update Mask.

The following is an example command for setting the value of the spec.osVersion field of the Device resource to 1.0.7.

## This command is dangerous (other fields in the spec are set to zero values)
cuttle devices update device $FULL_NAME \
  --spec '{"osVersion": "1.0.7"}'

## run with update mask to achieve intended operation
cuttle devices update device $FULL_NAME \
  --update-mask 'spec.osVersion' \
  --spec '{"osVersion": "1.0.7"}' \

Clearing a field

If you want to clear a field from a resource, specify update mask argument:

# This will set description to an empty string, whatever value is there.
$ cuttle iam update organization organizations/org-id --update-mask description -o json

Read operations

Read operations are: get, batch-get, list, occasionally search.

# Get a resource
$ cuttle devices get device projects/your-project/regions/us-west2/devices/dev-id-1 \
  <FIELD-MASK-ARGS> -o json
  
# Get 2 resources (note you need to specify param name each time)
$ cuttle devices batch-get devices \
  --names projects/your-project/regions/us-west2/devices/dev-id-1 \
  --names projects/your-project/regions/us-west2/devices/dev-id-2 \
  <FIELD-MASK-ARGS> -o json

# List operation (you can also specify --project and --region instead of --parent)
$ cuttle devices list devices --parent projects/your-project/regions/us-west2 \
  --filter '<FILTER STRING>' --order-by '<ORDER BY STRING>' <FIELD-MASK-ARGS> -o json
  
# Search is like list, but allows for additional --phrase argument. Be aware not
# all resources support search operations. Phrase must always be a string.
$ cuttle devices search devices --parent projects/your-project/regions/us-west2 \
  --phrase 'PHRASE STRING' --filter '<FILTER STRING>' --order-by '<ORDER BY STRING>' <FIELD-MASK-ARGS> -o json

Naturally filter, field mask and order by can be omitted if not needed.

Number of resources returned will be limited (100 by default), unless custom page size is configured.

Field mask arguments

By default, if you don’t specify any field mask arguments, service will provide pre-configured list of fields in a resource that developer configured in advance. If you compare cuttle output with resource specification, you will see some fields are usually missing. To provide an additional fields, you can specify extra paths using --field-mask <lowerCamelCase.nested> arguments (as many as you need).

$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' -o json \
  --field-mask 'status.connectionStatus' --field-mask 'spec.osVersion'

In the result, returned resources will contain pre-configured fields plus additional specified by --field-mask arguments.

If you don’t want to receive pre-configured paths, just the paths you need, you can add --view argument:

$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' -o json \
  --view NAME --field-mask 'status.connectionStatus' --field-mask 'spec.osVersion'

View NAME informs a service that it should return only name field of a resources matching specified parent name. You can then add specific field paths as needed.

Under the hood, cuttle uses actually --view BASIC if you don’t specify a view at all.

Collection reads within specific scope

Collection requests (list, search) typically require scope specification, for example using --parent argument. Optionally, specific segments like --project or --region.

$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' -o json

# This is equivalent
$ cuttle devices list devices --project 'your-project' --region 'us-west2' -o json

It is also possible to specify wildcards. For example, if we want to query devices from all the regions within a project, we can use - value:

$ cuttle devices list devices --parent 'projects/your-project/regions/-' -o json

# This is equivalent
$ cuttle devices list devices --project 'your-project' --region '-' -o json

Filtering

Some reading commands allow to use --filter arg. It must be a string with set of conditions connected using AND operator (if more than one condition is needed): fieldPath <OPERATOR> <VALUE> [AND ...]. Operator OR is not supported.

Field path may contain nested paths, each item must be connected with dot .. Field path items should use lowerCamelJson style.

Operators are:

  • Equality (=, !=, <, >, <=, >=)
  • In (IN, NOT IN)
  • Contains (CONTAINS, CONTAINS ANY, CONTAINS ALL)
  • Is Null (IS NULL) - this type does not require Value.

Certain operators require array value (IN, NOT IN, CONTAINS ANY/ALL). User needs to use [<ARG1>, <ARG2>, <ARG3>...] syntax.

# List connected devices within specified label
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --filter 'status.connectionStatus="CONNECTED" AND metadata.labels.key = "value"' -o json

# List devices using IN conditions
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --filter 'metadata.labels.key IN ["value1", "value2"]' -o json

# List devices without specified spec.serviceAccount field path.
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --filter 'spec.serviceAccount IS NULL' -o json

# List devices using CONTAINS operation
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --filter 'metadata.tags CONTAINS "value"' -o json

# List devices using CONTAINS ANY operation
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --filter 'metadata.tags CONTAINS ANY ["value1", "value2"]' -o json

# List alerts with state.lifetime.startTime after 2025 began in UTC (all policies and conditions)
$ cuttle monitoring list alerts --parent 'projects/your-project/regions/us-west2/alertingPolicies/-/alertingConditions/-' \
  --filter 'state.lifetime.startTime > "2025-01-01T00:00:00Z"' -o json

Note that name arguments like --parent, --project, or --region are kind of filter too!

Pagination

Collection requests like list/search offer pagination capabilities. Relevant arguments are: --order-by, --page-size and --page-token.

To retrieve first page of devices we can do the following:

# Fetch top 10 devices. Since --order-by is not specified, it automatically orders by name
# field in ascending order
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --page-size 10 -o json
  
# This is equivalent command as above, with explicit order
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --page-size 10 --order-by 'name ASC' -o json

# This sorts by display name instead in descending order.
$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --page-size 10 --order-by 'displayName DESC' -o json

It is allowed to sort by one column only as of now. If order by is specified by other field than name, service will sort additionally by name as secondary value though.

After receiving first response, you should see next page token if number of resources is greater than value provided by page size:

{"nextPageToken":"r.e.S.Ckxwcm9qZWN0cy9zY2FsZS10ZXN0LTIvcmVnaW9ucy9lYXN0dXMyL2RldmljZXMvcHAtdGVzdC1wcm92aXNpLXpudHY5OXA4em1meXV5"}

Then, you need to use --page-token argument to fetch the next page. Filter, parent and order by arguments must be same as before, otherwise results are not defined. Page size may be optionally changed. Tokens must be treated as opaque strings, not to be decoded.

$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --page-size 10 --order-by 'displayName DESC' \
  --page-token 'r.e.S.Ckxwcm9qZWN0cy9zY2FsZS10ZXN0LTIvcmVnaW9ucy9lYXN0dXMyL2RldmljZXMvcHAtdGVzdC1wcm92aXNpLXpudHY5OXA4em1meXV5' -o json

After requesting next page, you will have additional data below results:

{
  "nextPageToken": "r.e.S.Ckxwcm9qZWN0cy9zY2FsZS10ZXN0LTIvcmVnaW9ucy9lYXN0dXMyL2RldmljZXMvcHAtdGVzdC1wcm92aXNpLXJoa3Vqdmlta3hwNmpv",
  "prevPageToken": "l.i.S.Ckxwcm9qZWN0cy9zY2FsZS10ZXN0LTIvcmVnaW9ucy9lYXN0dXMyL2RldmljZXMvcHAtdGVzdC1wcm92aXNpLXpudHY5OXA4em1meXV5"
}

You can then use previous page token to come back to previous results. If you come back to the first page, prevPageToken will not be present anymore.

To retrieve total results counter, you need to specify -o json --raw-response true --include-paging-info true in the argument:

$ cuttle devices list devices --parent 'projects/your-project/regions/us-west2' \
  --page-size 10 --order-by 'displayName DESC' \
  -o json --raw-response --include-paging-info true

Unfortunately, as of now --include-paging-info does not work without --raw-response, which slightly changes output (stdout gets just full raw response as JSON).

In the JSON output from response, look out for totalResultsCount value. If you are paginated results, you will also see currentOffset.

Watch operations

Watch operations are long-running read operations (subscription for updates). There are 3 types:

  • Single resource watch
  • Stateful collection watch (paged)
  • Stateless collection watch (non-paged)

Note: All watch commands require -o json. Without this, you will not get anything on stdout. You can add | jq . at the end of any command for easier to read output.

# Watch specific device
$ cuttle devices watch device projects/your-project/regions/us-west2/devices/dev-id-1 -o json

# Watch first 10 devices (stateful)
$ cuttle devices watch devices --parent projects/your-project/regions/us-west2 \
  --type STATEFUL \
  --page-size 10 --order-by 'displayName ASC' -o json

# Watch devices in a project (stateless). Specify max number of devices in each
# response.
$ cuttle devices watch devices --parent projects/your-project/regions/us-west2 \
  --type STATELESS --max-chunk-size 10 -o json

After sending request, user will receive first response (snapshot). Cuttle process however will not quit, but instead hang on, appending more responses to the stdout - real time updates.

Single resource watch

It is very simple watch of a single, specific resource. It works very similar to get requests, except it provides real-time updates after initial response. User can specify --field-mask arguments (and --view), just like with get.

Server will skip real time updates if changed fields are not affecting watched fields.

Initial response will contain JSON like (assuming device is a resource name):

{
  "added": {
    "device": {/* resource body here */}
  }
}

If a resource is modified, users will get:

{
  "modified": {
    "name": "projects/your-project/regions/us-west2/devices/dev-id-1",
    "device": {/* resource body here */}
  }
}

If a watched resource is deleted, as of now, user will get NotFound error.

Stateful watch

Stateful watch is similar to List, except it provides real time updates following the initial snapshot.

User can specify (just like in list requests):

  • Parent/Filter arguments: --parent (or equivalent in --project, --region etc.), --filter
  • Pagination related: --order-by, --page-size, --page-token
  • Field masks: --view, --field-mask

Default page size is 100, if not specified. Default ordering is by name ascending. Effectively, stateful watch observes just a single page.

Initial snapshot (of devices) has following form:

{
  "deviceChanges": [
    {
      "added": {
        "device": {
          /* ... body ... */
        },
        "viewIndex": 0
      }
    },
    {
      "added": {
        "device": {
          /* ... body ... */
        },
        "viewIndex": 1
      }
    },
    {
      "added": {
        "device": {
          /* ... body ... */
        },
        "viewIndex": 2
      }
    }
    /* ... more entries ... */
  ],
  "isCurrent": true,
  "pageTokenChange": {
    "nextPageToken": "<TOKEN STRING VALUE>"
  },
  "snapshotSize": "-1"
}

In stateful watch type, returned resources are sorted, therefore they have positions. Each added entry contains position in viewIndex field. They are 0 indexed!

Apart from resource list, additional fields are:

  • isCurrent: Always true, not relevant for stateful watches
  • snapshotSize: Always -1, not relevant for stateful watches
  • pageTokenChange: Contains next/prev page tokens, if they changed from previous response. Always included in initial response.

Second and next stateful watch responses will contain only changes that happened on the page that is being observed. It means that:

  • Changes on resources outside --parent or --filter are not received.
  • Changes within --parent and --filter that are in the relevant scope, but outside --order-by, --page-size, --page-token, are also not received.
  • Only inserted/modified/removed resources are within changes list. For example, if initial list contained 100 objects, and 2 changed later on, subsequent response will contain just 2 objects. Client should update fetched page accordingly. Watch does not send full snapshot each time.

Subsequent responses are like:

{
  "deviceChanges": [
    {
      /* Record added is used for resources that are NEW on this page */
      "added": {
        "device": {
          /* ... body ... */
        },
        "viewIndex": 16 /* example value */
      }
    },
    {
      "modified": {
        "device": {
          /* ... body ... */
        },
        /* example values */
        "previousViewIndex": 16,
        "viewIndex": 16
      }
    },
    {
      "removed": {
        "name": "projects/your-project/regions/us-west2/devices/deleted-device-id",
        "viewIndex": 33 /* example value */
      }
    }
    /* ... more entries ... */
  ],
  "isCurrent": true,
  "pageTokenChange": {
    "nextPageToken": "<TOKEN STRING VALUE IF CHANGED>"
  },
  "snapshotSize": "-1"
}

Note there are 3 change types:

  • added: Informs that selected resource was inserted into the list on some specified position. It includes pre-existing resources that were got position into the list due to the modification.
  • modified: Informs that selected resource on the list was modified. If resource changed position on the list (due to changes in fields pointed by --order-by), then viewIndex will be different from previousViewIndex.
  • removed: Informs that selected resource was removed from the list. It includes cases when resource modifications that result in resource no longer matching --filter argument. Moreover, it includes cases when resource falls out of a view due to an insertion of a new resource above.

Notes about removed are important: They include not only deletions and modifications, but also can be sent for resources that did not change at all. All it takes, is for resource to fall outside of a view. For example, if we observe top 10 resources, and new one is created on position 3, two events will be in a change list:

  • removed, with viewIndex of value 9
  • added, with viewIndex of value 3

In stateful watch, change list must be applied in same order as in a response object. This is why, when new resource is inserted, we first have removal, then addition. If addition was executed first (and view index was 3), then item in removed object would need to have viewIndex equal to 10, not 9.

Stateless watch

Stateless watch is another collection-type watch (observes list of resources), but has following differences compared to the stateful one:

  • Pagination is not supported. Params --order-by, --page-size and --page-token have no meaning.
  • View indices in responses are meaningless as well, since resources are not ordered at all.
  • Initial snapshot may be sent in multiple responses, because they may contain potentially thousands of thousands of resources. This is chunking.
  • Responses will contain resume tokens. If connection is lost, client can reconnect and provide last received token to continue receiving updates from the last point.
  • Request object can specify resume token, or starting time from which we want to receive updates.
  • Response uses different change object types: current and removed, not added, modified. View index in removed has no meaning.

This watch type is not limited by page size - caller will receive all objects as long as they satisfy parent and filter fields.

There are multiple ways to establish this watch session:

# This will fetch full snapshot of devices in specified project/region
# Then, it will continue with real-time updates.
$ cuttle devices watch devices --parent projects/your-project/regions/us-west2 \
  --type STATELESS --max-chunk-size 10 -o json

# This will fetch historic updates from specified timestamp till now, then
# it will hang for real-time updates.
$ cuttle devices watch devices --parent projects/your-project/regions/us-west2 \
  --type STATELESS --max-chunk-size 10 --starting-time '2025-01-01T00:00:00Z' -o json
  
# This will fetch historic updates from resume token till now, then
# it will hang for real-time updates.
$ cuttle devices watch devices --parent projects/your-project/regions/us-west2 \
  --type STATELESS --max-chunk-size 10 --resume-token 'sjnckcml4r' -o json

Highlights:

  • Max chunk size is optional, 100 if not specified.
  • Resume token and starting time should not be used at the same time
  • If neither resume token or starting time were specified, backend will deliver full snapshot of resources.
  • Resume token can be obtained from previous watch only. It should be treated as opaque string, not to be decoded.
  • If resume token or starting time is too far into the past, backend may respond with an error. In that case, it is better to restart watch without neither specified, to get full snapshot.

If full snapshot is specified, then initial responses will look like:

{
  "deviceChanges": [
    {
      "current": {
        "device": {
          /* ... body ... */
        }
      }
    },
    {
      "current": {
        "device": {
          /* ... body ... */
        }
      }
    }
    /* ... more entries ... */
  ],
  "isCurrent": true,
  "resumeToken": "qdewf3f3",
  "snapshotSize": "-1"
}

However, be aware that field isCurrent may be false, and resumeToken empty, if snapshot turns larger than max chunk size. In that case, client will receive multiple responses, and only the last one will have isCurrent equal to true, and resumeToken populated.

In fact, if client receives response without isCurrent equal to true, client must wait for more responses until this condition is satisfied! This is true not only for initial snapshot, but any further updates.

After snapshot is received, next responses will have following form:

{
  "deviceChanges": [
    {
      "current": {
        "device": {
          /* ... body ... */
        }
      }
    },
    {
      "removed": {
        "name": "projects/your-project/regions/us-west2/devices/deleted-device-id"
      }
    }
    /* ... more entries ... */
  ],
  "isCurrent": true,
  "resumeToken": "dweqde",
  "snapshotSize": "-1"
}

Basically, clients should expect two change types:

  • current: Can describe creation or update. Resource may, or may not exist prior to the event.
  • removed: This can be deletion, or update that resulted in resource no longer satisfying filter field.

Client should keep track of the last resume token if needed.

Stateless watch type may deliver following special responses:

{
  "isSoftReset": true,
  "snapshotSize": "-1"
}

If isSoftReset is set to true, client must discard all received changes after last isCurrent was set to true. Let’s look at scenarios:

No-op scenario:

  • Client receives response with non-empty change list, and isCurrent is true
  • Client receives response with isSoftReset set to true.
  • Client does not need to discard anything, since there were no updates between soft reset event and last update with isCurrent equal to true.

With actual reset scenario:

  • Client receives response with non-empty change list, and isCurrent is true
  • Client receives response with non-empty change list, and isCurrent is false
  • Client receives response with isSoftReset set to true.
  • Client should discard second message, where isCurrent was false.

If isSoftReset is received during snapshot, it means whole snapshot needs to be discarded.

Other special response that client may receive, is hard reset:

{
  "isHardReset": true,
  "snapshotSize": "-1"
}

If hard reset is received, client must discard whole data it has. Hard reset will be followed by fresh snapshot.

Finally, there is a possibility of another special message, where snapshot size is equal or greater than 0:

{
  "snapshotSize": "1234"
}

If client receives this message, they must check if number of unique resources they have is equal to the snapshot size. If yes, nothing needs to be done. But, if number is wrong, client must disconnect and reconnect without resume token or starting time. This mismatch indicates that some events were lost.

This special message type however is limited to firestore backend type. If service uses mongo, this wont happen.