esp-morpheus 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- esp_morpheus/__init__.py +7 -0
- esp_morpheus/cli/__init__.py +3 -0
- esp_morpheus/cli/admin/__init__.py +173 -0
- esp_morpheus/cli/admin/botuser.py +212 -0
- esp_morpheus/cli/admin/gendevice.py +61 -0
- esp_morpheus/cli/admin/integrations.py +211 -0
- esp_morpheus/cli/admin/messaging.py +156 -0
- esp_morpheus/cli/admin/nodes.py +71 -0
- esp_morpheus/cli/admin/platforms.py +118 -0
- esp_morpheus/cli/admin/runtime.py +74 -0
- esp_morpheus/cli/admin/testdata.py +375 -0
- esp_morpheus/cli/context.py +399 -0
- esp_morpheus/cli/device.py +126 -0
- esp_morpheus/cli/groups.py +251 -0
- esp_morpheus/cli/guide.py +155 -0
- esp_morpheus/cli/main.py +176 -0
- esp_morpheus/cli/matter.py +139 -0
- esp_morpheus/cli/nodes.py +106 -0
- esp_morpheus/cli/output.py +360 -0
- esp_morpheus/cli/sharing.py +93 -0
- esp_morpheus/cli/shell.py +229 -0
- esp_morpheus/cli/sims.py +263 -0
- esp_morpheus/cli/user.py +191 -0
- esp_morpheus/data/examples/alexa_skills_config.example.json +5 -0
- esp_morpheus/data/examples/bot-iam-user-credentials.example.json +5 -0
- esp_morpheus/data/examples/gva_config.example.json +13 -0
- esp_morpheus/data/node_config_va_light.json +25 -0
- esp_morpheus/data/node_config_va_multi.json +264 -0
- esp_morpheus/data/node_config_va_switch.json +19 -0
- esp_morpheus/data/node_tags.json +11 -0
- esp_morpheus/data/test_config.default.json +48 -0
- esp_morpheus/outputs.py +292 -0
- esp_morpheus/paths.py +135 -0
- esp_morpheus/sdk/__init__.py +3 -0
- esp_morpheus/sdk/alexa_setup.py +645 -0
- esp_morpheus/sdk/device.py +1640 -0
- esp_morpheus/sdk/errors.py +92 -0
- esp_morpheus/sdk/group.py +269 -0
- esp_morpheus/sdk/gva_setup.py +233 -0
- esp_morpheus/sdk/kvs_viewer.py +2029 -0
- esp_morpheus/sdk/mailosaur.py +235 -0
- esp_morpheus/sdk/matter.py +249 -0
- esp_morpheus/sdk/ses.py +110 -0
- esp_morpheus/sdk/smartthings.py +122 -0
- esp_morpheus/sdk/user.py +3724 -0
- esp_morpheus/sdk/util.py +129 -0
- esp_morpheus/sims/__init__.py +3 -0
- esp_morpheus/sims/app.py +1473 -0
- esp_morpheus/sims/chal_resp_proto/__init__.py +0 -0
- esp_morpheus/sims/chal_resp_proto/esp_rmaker_chal_resp.proto +61 -0
- esp_morpheus/sims/chal_resp_proto/esp_rmaker_chal_resp_pb2.py +52 -0
- esp_morpheus/sims/chal_resp_proto/esp_rmaker_claim.proto +45 -0
- esp_morpheus/sims/chal_resp_proto/esp_rmaker_claim_pb2.py +44 -0
- esp_morpheus/sims/device.py +666 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/proto/constants.proto +18 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec0.proto +32 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec1.proto +49 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/proto/sec2.proto +52 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/proto/session.proto +29 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/python/constants_pb2.py +36 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec0_pb2.py +43 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec1_pb2.py +47 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/python/sec2_pb2.py +47 -0
- esp_morpheus/sims/idf_protocomm/components/protocomm/python/session_pb2.py +41 -0
- esp_morpheus/sims/prov_ble.py +336 -0
- esp_morpheus/sims/sparse_dep.py +190 -0
- esp_morpheus-0.9.0.dist-info/METADATA +434 -0
- esp_morpheus-0.9.0.dist-info/RECORD +72 -0
- esp_morpheus-0.9.0.dist-info/WHEEL +5 -0
- esp_morpheus-0.9.0.dist-info/entry_points.txt +2 -0
- esp_morpheus-0.9.0.dist-info/licenses/LICENSE +202 -0
- esp_morpheus-0.9.0.dist-info/top_level.txt +1 -0
esp_morpheus/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""`esp-morpheus admin ...` — every operation that needs AWS credentials or the admin API.
|
|
6
|
+
|
|
7
|
+
One tree holds the whole privileged surface, so what an ordinary caller can reach is exactly what
|
|
8
|
+
sits outside it: `user` and `device` go through the deployment's own API and broker and need no AWS
|
|
9
|
+
account, while everything here either signs in against the admin pool or calls AWS directly.
|
|
10
|
+
|
|
11
|
+
The identity is optional, because half of these commands need no identity at all — `ses`, `sns`,
|
|
12
|
+
`bot-user` and `test-data` reach AWS, not the admin API.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
import boto3
|
|
18
|
+
import click
|
|
19
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
20
|
+
|
|
21
|
+
from .. import output
|
|
22
|
+
from ..context import Session
|
|
23
|
+
from ..guide import guide
|
|
24
|
+
from ..shell import ContextGroup
|
|
25
|
+
from . import (
|
|
26
|
+
botuser,
|
|
27
|
+
gendevice,
|
|
28
|
+
integrations,
|
|
29
|
+
messaging,
|
|
30
|
+
nodes,
|
|
31
|
+
platforms,
|
|
32
|
+
runtime,
|
|
33
|
+
testdata,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
IDENTITY_HINT = ('name one with `esp-morpheus admin <identity>`, or seed one with '
|
|
37
|
+
'`esp-morpheus admin test-data setup`.')
|
|
38
|
+
|
|
39
|
+
# Where the group leaves the identity it took off the command line, for its own callback to read.
|
|
40
|
+
IDENTITY_META = 'morpheus.admin.identity'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _configured_admin(session):
|
|
44
|
+
"""The admin test_config.json flags, so the seeded deployment needs no identity."""
|
|
45
|
+
return next((u.get('name') for u in session.config.get('users', []) if u.get('admin')), None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _deployment_row(session, settings):
|
|
49
|
+
"""Which deployment every command here acts on."""
|
|
50
|
+
if settings is None:
|
|
51
|
+
return '[red]unresolved[/red]', session.settings_error
|
|
52
|
+
return f"{settings.account_id} / {settings.region}", _source_label(settings.source)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _source_label(source):
|
|
56
|
+
"""The outputs file, written relative to the working directory when it sits below it."""
|
|
57
|
+
if source.startswith(('http://', 'https://')):
|
|
58
|
+
return source
|
|
59
|
+
relative = os.path.relpath(source)
|
|
60
|
+
return source if relative.startswith(os.pardir) else relative
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _admin_api_row(session, identity, settings):
|
|
64
|
+
"""The admin account as (value, note, username), the last being what the prompt is labelled with.
|
|
65
|
+
|
|
66
|
+
Sign-in is reported, not enforced: the AWS half of this tree runs without an admin account.
|
|
67
|
+
"""
|
|
68
|
+
if not identity:
|
|
69
|
+
return '[yellow]none[/yellow]', IDENTITY_HINT, None
|
|
70
|
+
if settings is None:
|
|
71
|
+
return identity, 'not signed in: the deployment is unresolved', None
|
|
72
|
+
# Warn rather than abort: an admin that does not exist yet is created by `test-data setup`.
|
|
73
|
+
user = session.authenticated_user(on_failure='warn')
|
|
74
|
+
note = 'signed in' if getattr(user, 'token', None) else '[red]sign-in failed[/red]'
|
|
75
|
+
return f"[bold]{user.username}[/bold]", note, user.username
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _aws_row():
|
|
79
|
+
"""Where the ambient credentials come from, without spending an STS call to find out.
|
|
80
|
+
|
|
81
|
+
Resolving them for real would force credentials on a prompt whose admin-API half needs none.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
credentials = boto3.session.Session().get_credentials()
|
|
85
|
+
except (BotoCoreError, ClientError) as e:
|
|
86
|
+
return '[red]unusable[/red]', str(e)
|
|
87
|
+
if credentials is None:
|
|
88
|
+
return ('[yellow]none found[/yellow]',
|
|
89
|
+
'configure AWS_PROFILE, `aws sso login`, ~/.aws/credentials or the environment')
|
|
90
|
+
profile = os.environ.get('AWS_PROFILE')
|
|
91
|
+
source = getattr(credentials, 'method', None) or 'unknown source'
|
|
92
|
+
where = f"{source}, AWS_PROFILE={profile}" if profile else source
|
|
93
|
+
return where, 'account checked on the first AWS command'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _print_context(session, identity):
|
|
97
|
+
"""Name both identities this context holds, and return the one the prompt is labelled with.
|
|
98
|
+
|
|
99
|
+
Every row is built before anything prints, so a password prompt cannot land inside the banner.
|
|
100
|
+
"""
|
|
101
|
+
settings = session.try_settings()
|
|
102
|
+
api_value, api_note, username = _admin_api_row(session, identity, settings)
|
|
103
|
+
output.context_rows('Admin context', [
|
|
104
|
+
('deployment', *_deployment_row(session, settings)),
|
|
105
|
+
('admin API', api_value, api_note),
|
|
106
|
+
('AWS creds', *_aws_row()),
|
|
107
|
+
])
|
|
108
|
+
return username
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AdminGroup(ContextGroup):
|
|
112
|
+
"""`admin [IDENTITY] <command>`, where the identity may be left out.
|
|
113
|
+
|
|
114
|
+
click would read the first word as the identity and then find no command, so a first word that
|
|
115
|
+
names one of these commands is that command.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def parse_args(self, ctx, args):
|
|
119
|
+
if args and not args[0].startswith('-') and args[0] not in self.commands:
|
|
120
|
+
ctx.meta[IDENTITY_META] = args[0]
|
|
121
|
+
args = args[1:]
|
|
122
|
+
return super().parse_args(ctx, args)
|
|
123
|
+
|
|
124
|
+
def resolve_command(self, ctx, args):
|
|
125
|
+
"""Report a word taken as the identity, which is what a mistyped command becomes here."""
|
|
126
|
+
try:
|
|
127
|
+
return super().resolve_command(ctx, args)
|
|
128
|
+
except click.UsageError:
|
|
129
|
+
identity = ctx.meta.get(IDENTITY_META)
|
|
130
|
+
if not identity:
|
|
131
|
+
raise
|
|
132
|
+
raise click.UsageError(
|
|
133
|
+
f"No such command {args[0]!r}. The first word is read as the identity "
|
|
134
|
+
f"({identity!r}), so the command follows it.", ctx=ctx) from None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@click.group('admin', cls=AdminGroup, invoke_without_command=True,
|
|
138
|
+
options_metavar='', subcommand_metavar='[IDENTITY] [OPTIONS] COMMAND [ARGS]...',
|
|
139
|
+
repl_prompt='{label} > ', history='admin')
|
|
140
|
+
@click.option('--password', help='Password for an identity that test_config.json does not carry. '
|
|
141
|
+
'Also read from RMNG_PASSWORD, else prompted for. Prefer either '
|
|
142
|
+
'over argv, which other processes and shell history can see.')
|
|
143
|
+
@click.pass_context
|
|
144
|
+
def admin(ctx, password):
|
|
145
|
+
"""Administer the deployment, as IDENTITY where a command needs one.
|
|
146
|
+
|
|
147
|
+
IDENTITY is an admin: an email address, or an index or name in test_config.json. It defaults to
|
|
148
|
+
the admin test_config.json flags, and only the commands that call the admin API need one. With
|
|
149
|
+
no subcommand, this opens an interactive prompt.
|
|
150
|
+
"""
|
|
151
|
+
session = ctx.find_object(Session)
|
|
152
|
+
session.is_admin = True
|
|
153
|
+
session.password = password
|
|
154
|
+
session.identity_hint = IDENTITY_HINT
|
|
155
|
+
|
|
156
|
+
identity = ctx.meta.get(IDENTITY_META) or _configured_admin(session)
|
|
157
|
+
if identity:
|
|
158
|
+
session.select_user(identity)
|
|
159
|
+
if ctx.invoked_subcommand is not None:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
username = _print_context(session, identity)
|
|
163
|
+
ctx.command.open_shell(ctx, f"admin:{username}" if username else 'admin')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# `guide` needs nothing itself: it prints the console half of a setup whose other half is here.
|
|
167
|
+
for command in (botuser.bot_user, guide, integrations.integrations, messaging.ses, messaging.sns,
|
|
168
|
+
nodes.nodes, platforms.platforms, runtime.claiming, runtime.iot_event_mode,
|
|
169
|
+
testdata.test_data):
|
|
170
|
+
admin.add_command(command)
|
|
171
|
+
|
|
172
|
+
# Same reason, one level down: the node it writes is inert until `test-data setup` registers it.
|
|
173
|
+
testdata.test_data.add_command(gendevice.gen_device)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""`esp-morpheus admin bot-user ...` — the CI IAM user and its access keys."""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
import click
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from ... import paths
|
|
14
|
+
from .. import output
|
|
15
|
+
from ..context import pass_session
|
|
16
|
+
|
|
17
|
+
ADMINISTRATOR_ACCESS_POLICY_ARN = 'arn:aws:iam::aws:policy/AdministratorAccess'
|
|
18
|
+
|
|
19
|
+
# Name of the IAM user when nothing names one. The key holding a chosen name is written by
|
|
20
|
+
# `bot-user create`, so no entry has to exist before the first run.
|
|
21
|
+
DEFAULT_BOT_USER_NAME = 'bot'
|
|
22
|
+
BOT_USER_CONFIG_KEY = 'ci_bot_user'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group('bot-user')
|
|
26
|
+
def bot_user():
|
|
27
|
+
"""Create and delete the CI IAM bot user.
|
|
28
|
+
|
|
29
|
+
One bot at a time: there is a single credentials file, so a second user under another name would
|
|
30
|
+
overwrite the keys of the first and leave it in IAM with nothing tracking its access key.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _credentials_owner():
|
|
35
|
+
"""The user named by the credentials file, or None when there is no file and no usable name."""
|
|
36
|
+
path = paths.bot_credentials_path()
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
with open(path, encoding='utf-8') as handle:
|
|
41
|
+
return json.load(handle).get('user_name') or None
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tracked_bot_user_name(session):
|
|
47
|
+
"""The bot this checkout already holds keys for, or None.
|
|
48
|
+
|
|
49
|
+
The credentials file answers first: it is the record that a create would overwrite.
|
|
50
|
+
"""
|
|
51
|
+
return _credentials_owner() or session.config.get(BOT_USER_CONFIG_KEY) or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bot_user_name(session, name=None):
|
|
55
|
+
return name or _tracked_bot_user_name(session) or DEFAULT_BOT_USER_NAME
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _remember_bot_user_name(session, name):
|
|
59
|
+
"""Record the name so `delete` and `show` need no argument."""
|
|
60
|
+
if session.config.get(BOT_USER_CONFIG_KEY) == name:
|
|
61
|
+
return
|
|
62
|
+
session.config[BOT_USER_CONFIG_KEY] = name
|
|
63
|
+
session.write_config()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _forget_bot_user_name(session, name):
|
|
67
|
+
if session.config.get(BOT_USER_CONFIG_KEY) != name:
|
|
68
|
+
return
|
|
69
|
+
del session.config[BOT_USER_CONFIG_KEY]
|
|
70
|
+
session.write_config()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _iam_user_exists(iam, user_name):
|
|
74
|
+
try:
|
|
75
|
+
iam.get_user(UserName=user_name)
|
|
76
|
+
return True
|
|
77
|
+
except ClientError as e:
|
|
78
|
+
if e.response.get('Error', {}).get('Code') == 'NoSuchEntity':
|
|
79
|
+
return False
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _delete_access_keys(iam, name):
|
|
84
|
+
for page in iam.get_paginator('list_access_keys').paginate(UserName=name):
|
|
85
|
+
for meta in page.get('AccessKeyMetadata', []):
|
|
86
|
+
iam.delete_access_key(UserName=name, AccessKeyId=meta['AccessKeyId'])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_bot_credentials(name, key):
|
|
90
|
+
credentials_path = paths.bot_credentials_path()
|
|
91
|
+
paths.ensure(credentials_path.parent)
|
|
92
|
+
with open(credentials_path, 'w', encoding='utf-8') as handle:
|
|
93
|
+
json.dump({'user_name': name,
|
|
94
|
+
'access_key_id': key['AccessKeyId'],
|
|
95
|
+
'secret_access_key': key['SecretAccessKey']}, handle, indent=2)
|
|
96
|
+
try:
|
|
97
|
+
credentials_path.chmod(0o600)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
return credentials_path
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@bot_user.command('show')
|
|
104
|
+
@click.argument('name', required=False)
|
|
105
|
+
@pass_session
|
|
106
|
+
def show_bot_user(session, name):
|
|
107
|
+
"""Report the bot user's name, its IAM state and where its keys are."""
|
|
108
|
+
settings = session.require_aws()
|
|
109
|
+
name = _bot_user_name(session, name)
|
|
110
|
+
credentials_path = paths.bot_credentials_path()
|
|
111
|
+
iam = boto3.client('iam', region_name=settings.region)
|
|
112
|
+
|
|
113
|
+
tracked = _tracked_bot_user_name(session)
|
|
114
|
+
record = {'name': name, 'exists': _iam_user_exists(iam, name),
|
|
115
|
+
'credentials': str(credentials_path),
|
|
116
|
+
'credentials_present': credentials_path.is_file()}
|
|
117
|
+
if tracked and tracked != name:
|
|
118
|
+
# The keys on disk belong to somebody else, so they are not this user's.
|
|
119
|
+
record['credentials_belong_to'] = tracked
|
|
120
|
+
if record['exists']:
|
|
121
|
+
policies = iam.list_attached_user_policies(UserName=name).get('AttachedPolicies', [])
|
|
122
|
+
record['policies'] = [p['PolicyArn'] for p in policies] or ['(none)']
|
|
123
|
+
record['access_keys'] = sum(
|
|
124
|
+
len(page.get('AccessKeyMetadata', []))
|
|
125
|
+
for page in iam.get_paginator('list_access_keys').paginate(UserName=name))
|
|
126
|
+
output.emit_kv(None, record)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@bot_user.command('create')
|
|
130
|
+
@click.argument('name', required=False)
|
|
131
|
+
@click.option('--rotate', is_flag=True,
|
|
132
|
+
help='Replace the keys of an existing user instead of failing.')
|
|
133
|
+
@pass_session
|
|
134
|
+
def create_bot_user(session, name, rotate):
|
|
135
|
+
"""Create the IAM bot user with AdministratorAccess and write its access keys.
|
|
136
|
+
|
|
137
|
+
NAME defaults to the bot this checkout already tracks, then to "bot". The name used is saved
|
|
138
|
+
back to test_config.json, so `bot-user delete` needs no argument.
|
|
139
|
+
"""
|
|
140
|
+
settings = session.require_aws()
|
|
141
|
+
name = _bot_user_name(session, name)
|
|
142
|
+
iam = boto3.client('iam', region_name=settings.region)
|
|
143
|
+
tracked = _tracked_bot_user_name(session)
|
|
144
|
+
|
|
145
|
+
# A second name would overwrite the one credentials file and strand the first user in IAM with
|
|
146
|
+
# a live AdministratorAccess key. A tracked name IAM no longer has is stale, so it does not bite.
|
|
147
|
+
if tracked and tracked != name and _iam_user_exists(iam, tracked):
|
|
148
|
+
output.fail(f"This checkout already tracks IAM user {tracked!r}, and there is one "
|
|
149
|
+
f"credentials file. Run `esp-morpheus admin bot-user delete` before creating "
|
|
150
|
+
f"{name!r}, or `esp-morpheus admin bot-user create {tracked} --rotate` to issue it "
|
|
151
|
+
'a new key.')
|
|
152
|
+
|
|
153
|
+
exists = _iam_user_exists(iam, name)
|
|
154
|
+
if exists and not rotate:
|
|
155
|
+
output.fail(f"IAM user {name!r} already exists. Run `esp-morpheus admin bot-user create "
|
|
156
|
+
'--rotate` to issue a new key, or `esp-morpheus admin bot-user delete` to remove '
|
|
157
|
+
'the user.')
|
|
158
|
+
if exists:
|
|
159
|
+
# A rotate is the recovery path for a lost credentials file: IAM allows two keys per user,
|
|
160
|
+
# so clear the old ones before asking for another.
|
|
161
|
+
_delete_access_keys(iam, name)
|
|
162
|
+
else:
|
|
163
|
+
iam.create_user(UserName=name, Tags=[{'Key': 'Created-For', 'Value': 'Jenkins+CI'},
|
|
164
|
+
{'Key': 'Created-By', 'Value': 'Repository'}])
|
|
165
|
+
iam.attach_user_policy(UserName=name, PolicyArn=ADMINISTRATOR_ACCESS_POLICY_ARN)
|
|
166
|
+
key = iam.create_access_key(UserName=name)['AccessKey']
|
|
167
|
+
|
|
168
|
+
credentials_path = _write_bot_credentials(name, key)
|
|
169
|
+
_remember_bot_user_name(session, name)
|
|
170
|
+
output.ok(f"{'Rotated the key of' if exists else 'Created'} IAM user {name} with "
|
|
171
|
+
'AdministratorAccess')
|
|
172
|
+
output.emit_kv(None, {'credentials': str(credentials_path)})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@bot_user.command('delete')
|
|
176
|
+
@click.argument('name', required=False)
|
|
177
|
+
@pass_session
|
|
178
|
+
def delete_bot_user(session, name):
|
|
179
|
+
"""Delete the IAM bot user, its access keys and the credentials file."""
|
|
180
|
+
settings = session.require_aws()
|
|
181
|
+
name = _bot_user_name(session, name)
|
|
182
|
+
credentials_path = paths.bot_credentials_path()
|
|
183
|
+
iam = boto3.client('iam', region_name=settings.region)
|
|
184
|
+
|
|
185
|
+
# The file is another bot's only when it names a different user. One naming nobody is unusable,
|
|
186
|
+
# so this command is the last chance to clear it.
|
|
187
|
+
owner = _credentials_owner()
|
|
188
|
+
owns_credentials = owner is None or owner == name
|
|
189
|
+
|
|
190
|
+
if not _iam_user_exists(iam, name):
|
|
191
|
+
output.info(f"IAM user {name!r} does not exist; nothing to destroy.")
|
|
192
|
+
if owns_credentials and credentials_path.is_file():
|
|
193
|
+
credentials_path.unlink()
|
|
194
|
+
output.ok(f"Removed {credentials_path}")
|
|
195
|
+
_forget_bot_user_name(session, name)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
_delete_access_keys(iam, name)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
iam.detach_user_policy(UserName=name, PolicyArn=ADMINISTRATOR_ACCESS_POLICY_ARN)
|
|
202
|
+
except ClientError as e:
|
|
203
|
+
if e.response.get('Error', {}).get('Code') != 'NoSuchEntity':
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
iam.delete_user(UserName=name)
|
|
207
|
+
_forget_bot_user_name(session, name)
|
|
208
|
+
if owns_credentials and credentials_path.is_file():
|
|
209
|
+
credentials_path.unlink()
|
|
210
|
+
output.ok(f"Deleted IAM user {name} and removed {credentials_path}")
|
|
211
|
+
return
|
|
212
|
+
output.ok(f"Deleted IAM user {name}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""`esp-morpheus admin test-data gen-device` — a certificate and key for a node, generated locally.
|
|
6
|
+
|
|
7
|
+
It reaches nothing and needs no credentials. It sits with `test-data` because it owns the same
|
|
8
|
+
test_config.json, and the node it writes there does nothing until `test-data setup` registers it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ...sdk.device import generate_key_and_cert
|
|
14
|
+
from .. import output
|
|
15
|
+
from ..context import pass_session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command('gen-device')
|
|
19
|
+
@click.argument('node_name')
|
|
20
|
+
@click.argument('key_type', type=click.Choice(['rsa', 'ec']))
|
|
21
|
+
@click.option('--stdout', 'to_stdout', is_flag=True,
|
|
22
|
+
help='Print the certificate and key instead of writing test_config.json.')
|
|
23
|
+
@click.option('--force', is_flag=True,
|
|
24
|
+
help='Replace the certificate when NODE_NAME is already in test_config.json.')
|
|
25
|
+
@pass_session
|
|
26
|
+
def gen_device(session, node_name, key_type, to_stdout, force):
|
|
27
|
+
"""Generate a self-signed certificate and key for NODE_NAME.
|
|
28
|
+
|
|
29
|
+
Needs no deployment and no credentials. The node is appended to test_config.json, which is
|
|
30
|
+
written from the packaged defaults first when it does not exist yet. Register it with
|
|
31
|
+
`esp-morpheus admin test-data setup`, which re-runs idempotently over the nodes already seeded.
|
|
32
|
+
"""
|
|
33
|
+
key_pem, cert_pem = generate_key_and_cert(node_name, key_type)
|
|
34
|
+
entry = {'thing_name': node_name, 'cert': cert_pem, 'key': key_pem}
|
|
35
|
+
|
|
36
|
+
if to_stdout:
|
|
37
|
+
output.emit_kv(None, entry)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if not session.config_exists():
|
|
41
|
+
session.generate_config()
|
|
42
|
+
|
|
43
|
+
nodes = session.config.setdefault('nodes', [])
|
|
44
|
+
index = next((i for i, n in enumerate(nodes) if n.get('thing_name') == node_name), None)
|
|
45
|
+
if index is None:
|
|
46
|
+
nodes.append(entry)
|
|
47
|
+
index = len(nodes) - 1
|
|
48
|
+
action = 'Added'
|
|
49
|
+
elif force:
|
|
50
|
+
# Updated, not replaced, so node_cfg and associate_to survive a certificate regeneration.
|
|
51
|
+
nodes[index].update(entry)
|
|
52
|
+
action = 'Regenerated the certificate of'
|
|
53
|
+
else:
|
|
54
|
+
output.fail(f"{node_name} is already node {index} in test_config.json. Pass --force to "
|
|
55
|
+
'regenerate its certificate, which breaks the deployed node until the next '
|
|
56
|
+
'`admin test-data setup`.')
|
|
57
|
+
|
|
58
|
+
path = session.write_config()
|
|
59
|
+
output.ok(f"{action} {node_name} as node {index}")
|
|
60
|
+
output.emit_kv(None, {'thing_name': node_name, 'key_type': key_type,
|
|
61
|
+
'index': index, 'config': str(path)})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""`esp-morpheus admin integrations ...` — the voice-assistant credentials each skill needs."""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ...sdk import alexa_setup as alexa_smapi, gva_setup as gva_gcp
|
|
12
|
+
from .. import output
|
|
13
|
+
from ..context import Session, pass_session, pass_user
|
|
14
|
+
from ..guide import print_alexa_instructions
|
|
15
|
+
|
|
16
|
+
DEFAULT_ALEXA_CONFIG = 'alexa_skills_config.json'
|
|
17
|
+
ALEXA_CONFIG_KEYS = ('smapi_client_id', 'smapi_client_secret', 'vendor_id',
|
|
18
|
+
'smapi_refresh_token', 'redirect_urls', 'skill_name', 'skill_id')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_alexa_config(ctx, param, value):
|
|
22
|
+
"""Reject a missing config before the command logs in, naming the keys the file needs."""
|
|
23
|
+
del ctx, param
|
|
24
|
+
if not os.path.isfile(value):
|
|
25
|
+
raise click.UsageError(
|
|
26
|
+
f'Alexa config not found: {value}\n'
|
|
27
|
+
f"Pass --config PATH, or write the file with these keys: "
|
|
28
|
+
f"{', '.join(ALEXA_CONFIG_KEYS)}")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def alexa_config_option(command):
|
|
33
|
+
"""Attach the --config option every SMAPI command reads."""
|
|
34
|
+
return click.option(
|
|
35
|
+
'--config', 'config_file', default=DEFAULT_ALEXA_CONFIG, show_default=True,
|
|
36
|
+
type=click.Path(dir_okay=False), callback=_check_alexa_config, is_eager=True,
|
|
37
|
+
help='JSON file with the SMAPI credentials: ' + ', '.join(ALEXA_CONFIG_KEYS) + '.',
|
|
38
|
+
)(command)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group('integrations')
|
|
42
|
+
def integrations():
|
|
43
|
+
"""Configure the voice-assistant integrations."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@integrations.group('alexa')
|
|
47
|
+
def alexa():
|
|
48
|
+
"""Alexa smart-home skill configuration.
|
|
49
|
+
|
|
50
|
+
The SMAPI commands (setup-auto, list-skills, delete-skill) take `--config FILE`, which defaults
|
|
51
|
+
to alexa_skills_config.json. The setup command takes its own file as an argument.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _alexa_config_to_env(session, config_file):
|
|
56
|
+
"""Populate the env the SMAPI helper reads, so one config file drives both halves.
|
|
57
|
+
|
|
58
|
+
Example alexa_skills_config.json:
|
|
59
|
+
{"smapi_client_id": "...", "smapi_client_secret": "...",
|
|
60
|
+
"skill_name": "ESP RainMaker", "skill_id": "amzn1.ask.skill.xxxx"}
|
|
61
|
+
"""
|
|
62
|
+
config = output.read_json_file(config_file, 'Alexa config')
|
|
63
|
+
env_map = {
|
|
64
|
+
'SMAPI_CLIENT_ID': 'smapi_client_id',
|
|
65
|
+
'SMAPI_CLIENT_SECRET': 'smapi_client_secret',
|
|
66
|
+
'SMAPI_VENDOR_ID': 'vendor_id',
|
|
67
|
+
'SMAPI_REFRESH_TOKEN': 'smapi_refresh_token',
|
|
68
|
+
'ALEXA_REDIRECT_URLS': 'redirect_urls',
|
|
69
|
+
}
|
|
70
|
+
for env_key, config_key in env_map.items():
|
|
71
|
+
value = config.get(config_key)
|
|
72
|
+
if value:
|
|
73
|
+
os.environ[env_key] = ','.join(value) if isinstance(value, list) else str(value)
|
|
74
|
+
# alexa_setup reads outputs itself, keyed off RMNG_OUTPUTS; point it at the source esp-morpheus
|
|
75
|
+
# resolved so --client-outputs applies there too.
|
|
76
|
+
os.environ['RMNG_OUTPUTS'] = session.settings.source
|
|
77
|
+
return config
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@alexa.command('setup')
|
|
81
|
+
@click.argument('config_file', type=click.Path(exists=True, dir_okay=False))
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def alexa_config(ctx, config_file):
|
|
84
|
+
"""Store the Alexa skill credentials from CONFIG_FILE in the backend."""
|
|
85
|
+
session = ctx.find_object(Session)
|
|
86
|
+
config = output.read_json_file(config_file, 'Alexa config')
|
|
87
|
+
missing = [k for k in ('redirect_urls', 'alexa_client_id', 'alexa_client_secret', 'skill_id')
|
|
88
|
+
if not config.get(k)]
|
|
89
|
+
if missing:
|
|
90
|
+
output.fail(f"{config_file} is missing {', '.join(missing)}")
|
|
91
|
+
|
|
92
|
+
output.emit_response(session.user.alexa_post_configuration(
|
|
93
|
+
redirect_uris=config['redirect_urls'], client_id=config['alexa_client_id'],
|
|
94
|
+
client_secret=config['alexa_client_secret'], skill_id=config['skill_id']),
|
|
95
|
+
'Alexa configuration stored')
|
|
96
|
+
print_alexa_instructions(session.settings)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@alexa.command('setup-auto')
|
|
100
|
+
@alexa_config_option
|
|
101
|
+
@click.argument('skill_name', nargs=-1)
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def alexa_auto(ctx, config_file, skill_name):
|
|
104
|
+
"""Create or update the skill over SMAPI, then store its configuration.
|
|
105
|
+
|
|
106
|
+
The config POST goes through this admin's session, so no separate AWS credentials are needed.
|
|
107
|
+
"""
|
|
108
|
+
session = ctx.find_object(Session)
|
|
109
|
+
config = _alexa_config_to_env(session, config_file)
|
|
110
|
+
|
|
111
|
+
def post_config(skill_id, client_id, client_secret, redirect_uris):
|
|
112
|
+
output.emit_response(session.user.alexa_post_configuration(
|
|
113
|
+
redirect_uris=redirect_uris, client_id=client_id,
|
|
114
|
+
client_secret=client_secret, skill_id=skill_id), 'Alexa configuration stored')
|
|
115
|
+
|
|
116
|
+
alexa_smapi.setup(post_config, skill_id=config.get('skill_id'),
|
|
117
|
+
skill_name=' '.join(skill_name) or config.get('skill_name'))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@alexa.command('list-skills')
|
|
121
|
+
@alexa_config_option
|
|
122
|
+
@pass_session
|
|
123
|
+
def alexa_list(session, config_file):
|
|
124
|
+
"""List every Alexa skill under the vendor."""
|
|
125
|
+
_alexa_config_to_env(session, config_file)
|
|
126
|
+
alexa_smapi.list_all()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@alexa.command('delete-skill')
|
|
130
|
+
@click.argument('skill_id')
|
|
131
|
+
@alexa_config_option
|
|
132
|
+
@pass_session
|
|
133
|
+
def alexa_delete(session, skill_id, config_file):
|
|
134
|
+
"""Delete the Alexa skill SKILL_ID."""
|
|
135
|
+
_alexa_config_to_env(session, config_file)
|
|
136
|
+
alexa_smapi.delete(skill_id)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@integrations.group('gva')
|
|
140
|
+
def gva():
|
|
141
|
+
"""Google Home integration."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@gva.command('setup')
|
|
145
|
+
@click.argument('service_account_json', type=click.Path(exists=True, dir_okay=False))
|
|
146
|
+
@click.pass_context
|
|
147
|
+
def gva_setup(ctx, service_account_json):
|
|
148
|
+
"""Upload the HomeGraph service-account key in SERVICE_ACCOUNT_JSON."""
|
|
149
|
+
session = ctx.find_object(Session)
|
|
150
|
+
config = output.read_json_file(service_account_json, 'service account')
|
|
151
|
+
output.emit_response(session.user.gva_post_configuration(config),
|
|
152
|
+
'Google Home configuration stored')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@gva.command('setup-auto')
|
|
156
|
+
@click.argument('project_id')
|
|
157
|
+
@click.argument('service_account_name', required=False)
|
|
158
|
+
@pass_user
|
|
159
|
+
def gva_auto(user, project_id, service_account_name):
|
|
160
|
+
"""Enable the HomeGraph API, create the report-state service account and key on PROJECT_ID,
|
|
161
|
+
then store the key.
|
|
162
|
+
|
|
163
|
+
Replaces the console procedure in the spec (enable API, create account, grant the OpenID
|
|
164
|
+
Connect Identity Token Creator role, download a key, re-upload it). The Google-side work runs
|
|
165
|
+
locally against your `gcloud` credentials; only the config POST goes through this admin's
|
|
166
|
+
session, so no separate AWS credentials are needed.
|
|
167
|
+
|
|
168
|
+
The Google Home Developer Console part (creating the Home project and the cloud-to-cloud
|
|
169
|
+
integration) has no public API and stays manual.
|
|
170
|
+
"""
|
|
171
|
+
def post_config(service_account_json):
|
|
172
|
+
output.emit_response(user.gva_post_configuration(service_account_json),
|
|
173
|
+
'Google Home configuration stored')
|
|
174
|
+
|
|
175
|
+
gva_gcp.setup(post_config, project_id, service_account_name)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@integrations.group('smartthings')
|
|
179
|
+
def smartthings():
|
|
180
|
+
"""SmartThings Schema App integration."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@smartthings.command('setup')
|
|
184
|
+
@click.argument('config_file', type=click.Path(exists=True, dir_okay=False))
|
|
185
|
+
@pass_user
|
|
186
|
+
def st_setup(user, config_file):
|
|
187
|
+
"""Store the credentials SmartThings issued, read from CONFIG_FILE.
|
|
188
|
+
|
|
189
|
+
Read from a file rather than argv: the client secret is 512 characters, and a secret in argv is
|
|
190
|
+
visible to other processes and lands in shell history.
|
|
191
|
+
"""
|
|
192
|
+
config = output.read_json_file(config_file, 'SmartThings config')
|
|
193
|
+
missing = [field for field in ('client_id', 'client_secret') if not config.get(field)]
|
|
194
|
+
if missing:
|
|
195
|
+
output.fail(f"{config_file} is missing {', '.join(missing)}")
|
|
196
|
+
output.emit_response(user.st_post_configuration(config['client_id'], config['client_secret']),
|
|
197
|
+
'SmartThings configuration stored')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@smartthings.command('get-config')
|
|
201
|
+
@pass_user
|
|
202
|
+
def st_get(user):
|
|
203
|
+
"""Show the stored SmartThings configuration."""
|
|
204
|
+
output.emit_response(user.st_get_configuration())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@smartthings.command('delete-config')
|
|
208
|
+
@pass_user
|
|
209
|
+
def st_delete(user):
|
|
210
|
+
"""Delete the stored SmartThings configuration."""
|
|
211
|
+
output.emit_response(user.st_delete_configuration(), 'SmartThings configuration deleted')
|