stratactl 1.0.0__tar.gz
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.
- stratactl-1.0.0/PKG-INFO +119 -0
- stratactl-1.0.0/README.md +85 -0
- stratactl-1.0.0/pyproject.toml +73 -0
- stratactl-1.0.0/setup.cfg +4 -0
- stratactl-1.0.0/src/strata/__init__.py +8 -0
- stratactl-1.0.0/src/strata/__main__.py +4 -0
- stratactl-1.0.0/src/strata/artifacts/__init__.py +4 -0
- stratactl-1.0.0/src/strata/artifacts/bundle.py +111 -0
- stratactl-1.0.0/src/strata/artifacts/manifest.py +125 -0
- stratactl-1.0.0/src/strata/artifacts/source.py +60 -0
- stratactl-1.0.0/src/strata/aws/__init__.py +0 -0
- stratactl-1.0.0/src/strata/aws/session.py +34 -0
- stratactl-1.0.0/src/strata/bootstrap/__init__.py +29 -0
- stratactl-1.0.0/src/strata/bootstrap/infra/main.tf +313 -0
- stratactl-1.0.0/src/strata/bootstrap/infra/outputs.tf +50 -0
- stratactl-1.0.0/src/strata/bootstrap/infra/variables.tf +46 -0
- stratactl-1.0.0/src/strata/bootstrap/infra/versions.tf +16 -0
- stratactl-1.0.0/src/strata/cli.py +161 -0
- stratactl-1.0.0/src/strata/commands/__init__.py +9 -0
- stratactl-1.0.0/src/strata/commands/_common.py +173 -0
- stratactl-1.0.0/src/strata/commands/bootstrap.py +121 -0
- stratactl-1.0.0/src/strata/commands/dashboards.py +70 -0
- stratactl-1.0.0/src/strata/commands/dev.py +150 -0
- stratactl-1.0.0/src/strata/commands/fetch_runtime.py +51 -0
- stratactl-1.0.0/src/strata/commands/init.py +147 -0
- stratactl-1.0.0/src/strata/commands/install.py +11 -0
- stratactl-1.0.0/src/strata/commands/list.py +20 -0
- stratactl-1.0.0/src/strata/commands/logs.py +29 -0
- stratactl-1.0.0/src/strata/commands/platform.py +58 -0
- stratactl-1.0.0/src/strata/commands/uninstall.py +11 -0
- stratactl-1.0.0/src/strata/commands/update.py +11 -0
- stratactl-1.0.0/src/strata/config/__init__.py +4 -0
- stratactl-1.0.0/src/strata/config/defaults.toml +46 -0
- stratactl-1.0.0/src/strata/config/models.py +66 -0
- stratactl-1.0.0/src/strata/config/provider.py +228 -0
- stratactl-1.0.0/src/strata/dashboards/__init__.py +3 -0
- stratactl-1.0.0/src/strata/dashboards/sync.py +264 -0
- stratactl-1.0.0/src/strata/errors.py +29 -0
- stratactl-1.0.0/src/strata/hq/__init__.py +3 -0
- stratactl-1.0.0/src/strata/hq/client.py +132 -0
- stratactl-1.0.0/src/strata/install/__init__.py +19 -0
- stratactl-1.0.0/src/strata/install/modules.py +103 -0
- stratactl-1.0.0/src/strata/install/prompt.py +185 -0
- stratactl-1.0.0/src/strata/install/schema.py +132 -0
- stratactl-1.0.0/src/strata/install/store.py +99 -0
- stratactl-1.0.0/src/strata/install/tfvars.py +142 -0
- stratactl-1.0.0/src/strata/install/validators.py +97 -0
- stratactl-1.0.0/src/strata/registry/__init__.py +3 -0
- stratactl-1.0.0/src/strata/registry/installs.py +88 -0
- stratactl-1.0.0/src/strata/registry/jobs.py +115 -0
- stratactl-1.0.0/src/strata/runner/__init__.py +3 -0
- stratactl-1.0.0/src/strata/runner/__main__.py +9 -0
- stratactl-1.0.0/src/strata/runner/launcher.py +54 -0
- stratactl-1.0.0/src/strata/runner/logs.py +69 -0
- stratactl-1.0.0/src/strata/runner/main.py +229 -0
- stratactl-1.0.0/src/strata/runner/permissions.py +74 -0
- stratactl-1.0.0/src/strata/runner/sensitive.py +49 -0
- stratactl-1.0.0/src/stratactl.egg-info/PKG-INFO +119 -0
- stratactl-1.0.0/src/stratactl.egg-info/SOURCES.txt +63 -0
- stratactl-1.0.0/src/stratactl.egg-info/dependency_links.txt +1 -0
- stratactl-1.0.0/src/stratactl.egg-info/entry_points.txt +2 -0
- stratactl-1.0.0/src/stratactl.egg-info/requires.txt +11 -0
- stratactl-1.0.0/src/stratactl.egg-info/top_level.txt +1 -0
- stratactl-1.0.0/tests/test_dev_source_resolution.py +47 -0
- stratactl-1.0.0/tests/test_install.py +182 -0
stratactl-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stratactl
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: StrataCTL — bootstrap StrataCI into a customer AWS account and install/update/uninstall StrataBI modules via an ephemeral CodeBuild runner.
|
|
5
|
+
Author-email: Shaleio LLC <info@shaleio.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://shaleio.com
|
|
8
|
+
Project-URL: Documentation, https://shaleio.com/docs
|
|
9
|
+
Project-URL: Source, https://github.com/shaleio/stratactl
|
|
10
|
+
Project-URL: Issues, https://github.com/shaleio/stratactl/issues
|
|
11
|
+
Keywords: stratabi,aws,codebuild,opentofu,terraform,cli,analytics
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: boto3>=1.34
|
|
26
|
+
Requires-Dist: pyyaml>=6
|
|
27
|
+
Requires-Dist: platformdirs>=4
|
|
28
|
+
Provides-Extra: runner
|
|
29
|
+
Requires-Dist: cryptography>=42; extra == "runner"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
34
|
+
|
|
35
|
+
# StrataCTL
|
|
36
|
+
|
|
37
|
+
The local command-line tool for operators. It installs, updates, and removes
|
|
38
|
+
StrataBI modules and the StrataBI platform in **your** AWS account, fetches the
|
|
39
|
+
account-entitled runtime, and version-controls dashboards. It runs on your machine
|
|
40
|
+
with your own AWS credentials — it is not a hosted service.
|
|
41
|
+
|
|
42
|
+
The same `strata` package is also the **runner program** that executes inside the
|
|
43
|
+
ephemeral CodeBuild runner (`python -m strata.runner`).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python3 -m venv ~/.venvs/strata && source ~/.venvs/strata/bin/activate
|
|
49
|
+
pip install -e . # from this directory; exposes the `stratactl` command
|
|
50
|
+
stratactl --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configure
|
|
54
|
+
|
|
55
|
+
`strata init` collects a deployment's configuration into `strata.yaml` (the source
|
|
56
|
+
of truth) and generates the Terraform variable files — you don't hand-edit `.tfvars`.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
stratactl init # interactive; re-run to reuse answers + fill gaps
|
|
60
|
+
stratactl init --non-interactive # config + defaults only (automation)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Settings resolve in this precedence (highest first): **CLI flag → env (`STRATA_*`)
|
|
64
|
+
→ `./.stratactl.toml` → `~/.stratactl/config.toml` → AWS SSM (`/strataci/hq/*`) →
|
|
65
|
+
packaged defaults**. Point it at a customer account by pasting the
|
|
66
|
+
`stratactl_customer_config` output from `strataci-runner` into your config, and set
|
|
67
|
+
your license token:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export STRATA_HQ_TOKEN=<your license token>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
AWS credentials come from your normal profile/keys (`--profile`, `AWS_PROFILE`, env).
|
|
74
|
+
What you can deploy is governed by your own IAM in the target account.
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
stratactl init # configure a deployment (writes strata.yaml + tfvars)
|
|
80
|
+
stratactl fetch-runtime --version 1.0.0 # pull the account-entitled runtime wheel -> ./dist (verified)
|
|
81
|
+
stratactl install <module> # entitlement -> runner -> tofu apply, in your account
|
|
82
|
+
stratactl install-stratabi # install the StrataBI platform (Enterprise)
|
|
83
|
+
stratactl install-stratabi-dev # install the Developer/Community edition
|
|
84
|
+
stratactl update <module> --to <version>
|
|
85
|
+
stratactl uninstall <module> # runs the module's tofu destroy (always available)
|
|
86
|
+
stratactl list # what's installed + delivery mode
|
|
87
|
+
stratactl logs <job-id> # tail/replay a runner build
|
|
88
|
+
stratactl dashboards pull|push|list|diff # git-sync dashboards (no runner needed)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`--source stratahq` (default) fetches a signed bundle from Shaleio's control plane;
|
|
92
|
+
`--source customer_s3` installs from your own bucket (offline / air-gapped).
|
|
93
|
+
|
|
94
|
+
## End-to-end: deploy StrataBI Enterprise
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# 0. one-time per account: scaffold the runner (see ../strataci-runner)
|
|
98
|
+
# 1. build the runtime container image (see ../build_template) -> ECR image URI
|
|
99
|
+
# 2. configure + deploy:
|
|
100
|
+
export STRATA_HQ_TOKEN=<token>
|
|
101
|
+
stratactl init # set stratabi_image=<ECR URI>, vpc_id, subnets, allowed_ip_cidrs
|
|
102
|
+
stratactl install-stratabi
|
|
103
|
+
stratactl list
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Your `strata init` values ride through to the ephemeral runner automatically (the
|
|
107
|
+
CLI uploads them; the runner applies them over the bundle's baked defaults), so the
|
|
108
|
+
platform deploys with your networking/image without any manual `.tfvars` on the runner.
|
|
109
|
+
|
|
110
|
+
## Layout
|
|
111
|
+
|
|
112
|
+
| Path | What |
|
|
113
|
+
|------|------|
|
|
114
|
+
| `src/strata/commands/` | CLI subcommands (`init`, `install`, `fetch_runtime`, `dashboards`, …) |
|
|
115
|
+
| `src/strata/hq/` | StrataHQ client (SigV4-signed entitlement calls) |
|
|
116
|
+
| `src/strata/runner/` | the program that runs inside CodeBuild |
|
|
117
|
+
| `src/strata/install/` | the `strata init` wizard (schema / prompt / store / tfvars) |
|
|
118
|
+
| `src/strata/artifacts/` | bundle download / verify / manifest |
|
|
119
|
+
| `tests/` | unit tests (`pytest`) |
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# StrataCTL
|
|
2
|
+
|
|
3
|
+
The local command-line tool for operators. It installs, updates, and removes
|
|
4
|
+
StrataBI modules and the StrataBI platform in **your** AWS account, fetches the
|
|
5
|
+
account-entitled runtime, and version-controls dashboards. It runs on your machine
|
|
6
|
+
with your own AWS credentials — it is not a hosted service.
|
|
7
|
+
|
|
8
|
+
The same `strata` package is also the **runner program** that executes inside the
|
|
9
|
+
ephemeral CodeBuild runner (`python -m strata.runner`).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python3 -m venv ~/.venvs/strata && source ~/.venvs/strata/bin/activate
|
|
15
|
+
pip install -e . # from this directory; exposes the `stratactl` command
|
|
16
|
+
stratactl --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configure
|
|
20
|
+
|
|
21
|
+
`strata init` collects a deployment's configuration into `strata.yaml` (the source
|
|
22
|
+
of truth) and generates the Terraform variable files — you don't hand-edit `.tfvars`.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
stratactl init # interactive; re-run to reuse answers + fill gaps
|
|
26
|
+
stratactl init --non-interactive # config + defaults only (automation)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Settings resolve in this precedence (highest first): **CLI flag → env (`STRATA_*`)
|
|
30
|
+
→ `./.stratactl.toml` → `~/.stratactl/config.toml` → AWS SSM (`/strataci/hq/*`) →
|
|
31
|
+
packaged defaults**. Point it at a customer account by pasting the
|
|
32
|
+
`stratactl_customer_config` output from `strataci-runner` into your config, and set
|
|
33
|
+
your license token:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export STRATA_HQ_TOKEN=<your license token>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
AWS credentials come from your normal profile/keys (`--profile`, `AWS_PROFILE`, env).
|
|
40
|
+
What you can deploy is governed by your own IAM in the target account.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
stratactl init # configure a deployment (writes strata.yaml + tfvars)
|
|
46
|
+
stratactl fetch-runtime --version 1.0.0 # pull the account-entitled runtime wheel -> ./dist (verified)
|
|
47
|
+
stratactl install <module> # entitlement -> runner -> tofu apply, in your account
|
|
48
|
+
stratactl install-stratabi # install the StrataBI platform (Enterprise)
|
|
49
|
+
stratactl install-stratabi-dev # install the Developer/Community edition
|
|
50
|
+
stratactl update <module> --to <version>
|
|
51
|
+
stratactl uninstall <module> # runs the module's tofu destroy (always available)
|
|
52
|
+
stratactl list # what's installed + delivery mode
|
|
53
|
+
stratactl logs <job-id> # tail/replay a runner build
|
|
54
|
+
stratactl dashboards pull|push|list|diff # git-sync dashboards (no runner needed)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`--source stratahq` (default) fetches a signed bundle from Shaleio's control plane;
|
|
58
|
+
`--source customer_s3` installs from your own bucket (offline / air-gapped).
|
|
59
|
+
|
|
60
|
+
## End-to-end: deploy StrataBI Enterprise
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 0. one-time per account: scaffold the runner (see ../strataci-runner)
|
|
64
|
+
# 1. build the runtime container image (see ../build_template) -> ECR image URI
|
|
65
|
+
# 2. configure + deploy:
|
|
66
|
+
export STRATA_HQ_TOKEN=<token>
|
|
67
|
+
stratactl init # set stratabi_image=<ECR URI>, vpc_id, subnets, allowed_ip_cidrs
|
|
68
|
+
stratactl install-stratabi
|
|
69
|
+
stratactl list
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Your `strata init` values ride through to the ephemeral runner automatically (the
|
|
73
|
+
CLI uploads them; the runner applies them over the bundle's baked defaults), so the
|
|
74
|
+
platform deploys with your networking/image without any manual `.tfvars` on the runner.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
| Path | What |
|
|
79
|
+
|------|------|
|
|
80
|
+
| `src/strata/commands/` | CLI subcommands (`init`, `install`, `fetch_runtime`, `dashboards`, …) |
|
|
81
|
+
| `src/strata/hq/` | StrataHQ client (SigV4-signed entitlement calls) |
|
|
82
|
+
| `src/strata/runner/` | the program that runs inside CodeBuild |
|
|
83
|
+
| `src/strata/install/` | the `strata init` wizard (schema / prompt / store / tfvars) |
|
|
84
|
+
| `src/strata/artifacts/` | bundle download / verify / manifest |
|
|
85
|
+
| `tests/` | unit tests (`pytest`) |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# PyPI distribution name (intended; verify availability before first publish).
|
|
7
|
+
name = "stratactl"
|
|
8
|
+
# Versioning is Major.Sprint.Patch (Shaleio ecosystem sprint clock), not SemVer.
|
|
9
|
+
# Sprint 0 begins 2026-07-27. See "Versioning" in the README.
|
|
10
|
+
version = "1.0.0"
|
|
11
|
+
description = "StrataCTL — bootstrap StrataCI into a customer AWS account and install/update/uninstall StrataBI modules via an ephemeral CodeBuild runner."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
license = { text = "Apache-2.0" }
|
|
15
|
+
authors = [{ name = "Shaleio LLC", email = "info@shaleio.com" }]
|
|
16
|
+
keywords = ["stratabi", "aws", "codebuild", "opentofu", "terraform", "cli", "analytics"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"License :: OSI Approved :: Apache Software License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: System :: Installation/Setup",
|
|
28
|
+
"Topic :: System :: Systems Administration",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"boto3>=1.34",
|
|
32
|
+
"pyyaml>=6",
|
|
33
|
+
"platformdirs>=4",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
# Installed in the runner image/buildspec — needed for offline bundle-signature
|
|
38
|
+
# verification (Decision C). The CLI itself does not require it.
|
|
39
|
+
runner = ["cryptography>=42"]
|
|
40
|
+
# Development / test tooling.
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8",
|
|
43
|
+
"build>=1",
|
|
44
|
+
"twine>=5",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://shaleio.com"
|
|
49
|
+
Documentation = "https://shaleio.com/docs"
|
|
50
|
+
Source = "https://github.com/shaleio/stratactl"
|
|
51
|
+
Issues = "https://github.com/shaleio/stratactl/issues"
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
stratactl = "strata.cli:main"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.package-data]
|
|
60
|
+
# Ship every non-.py asset the CLI + bootstrap need at runtime: config defaults,
|
|
61
|
+
# bundled OpenTofu templates, install schemas, and the packaged StrataCI bootstrap
|
|
62
|
+
# infrastructure (see src/strata/bootstrap/infra/ — versioned, immutable package data).
|
|
63
|
+
strata = [
|
|
64
|
+
"config/*.toml",
|
|
65
|
+
"bootstrap/**/*",
|
|
66
|
+
"install/**/*.json",
|
|
67
|
+
"**/*.tf",
|
|
68
|
+
"**/*.tftpl",
|
|
69
|
+
"**/*.json",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Artifact bundle handling, used by the runner: download, verify, safe-extract.
|
|
2
|
+
|
|
3
|
+
Bundle layout:
|
|
4
|
+
module.zip
|
|
5
|
+
├── stratabi-module.json
|
|
6
|
+
├── tofu/{main,variables,outputs,versions}.tf
|
|
7
|
+
├── config/defaults.json
|
|
8
|
+
└── README.md
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import urllib.request
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from strata.errors import ArtifactError
|
|
20
|
+
|
|
21
|
+
MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # 256 MB cap
|
|
22
|
+
MAX_FILES = 5000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def download_url(url: str, dest: Path, timeout: int = 60) -> Path:
|
|
26
|
+
"""Download a presigned (or any) URL to dest. Never logs the URL."""
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp, open(dest, "wb") as fh:
|
|
29
|
+
total = 0
|
|
30
|
+
while chunk := resp.read(1024 * 256):
|
|
31
|
+
total += len(chunk)
|
|
32
|
+
if total > MAX_BUNDLE_BYTES:
|
|
33
|
+
raise ArtifactError("bundle exceeds size cap")
|
|
34
|
+
fh.write(chunk)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
raise ArtifactError(f"failed to download artifact: {exc}") from exc
|
|
37
|
+
return dest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def download_s3(s3_client, s3_uri: str, dest: Path) -> Path:
|
|
41
|
+
if not s3_uri.startswith("s3://"):
|
|
42
|
+
raise ArtifactError(f"not an s3 uri: {s3_uri}")
|
|
43
|
+
bucket, key = s3_uri[len("s3://"):].split("/", 1)
|
|
44
|
+
try:
|
|
45
|
+
s3_client.download_file(bucket, key, str(dest))
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
raise ArtifactError(f"failed to download {s3_uri}: {exc}") from exc
|
|
48
|
+
return dest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_sha256(path: Path, expected: Optional[str]) -> str:
|
|
52
|
+
h = hashlib.sha256()
|
|
53
|
+
with open(path, "rb") as fh:
|
|
54
|
+
while chunk := fh.read(1024 * 256):
|
|
55
|
+
h.update(chunk)
|
|
56
|
+
actual = h.hexdigest()
|
|
57
|
+
if expected and actual.lower() != expected.lower():
|
|
58
|
+
raise ArtifactError(
|
|
59
|
+
f"artifact checksum mismatch (expected {expected[:12]}…, got {actual[:12]}…)"
|
|
60
|
+
)
|
|
61
|
+
return actual
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def safe_extract(zip_path: Path, workdir: Path) -> Path:
|
|
65
|
+
"""Extract a zip, rejecting absolute paths / traversal / overlarge bundles."""
|
|
66
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
try:
|
|
68
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
69
|
+
members = zf.namelist()
|
|
70
|
+
if len(members) > MAX_FILES:
|
|
71
|
+
raise ArtifactError("bundle has too many entries")
|
|
72
|
+
for name in members:
|
|
73
|
+
target = (workdir / name).resolve()
|
|
74
|
+
if not str(target).startswith(str(workdir.resolve())):
|
|
75
|
+
raise ArtifactError(f"unsafe path in bundle: {name}")
|
|
76
|
+
zf.extractall(workdir)
|
|
77
|
+
except zipfile.BadZipFile as exc:
|
|
78
|
+
raise ArtifactError(f"corrupt bundle: {exc}") from exc
|
|
79
|
+
return workdir
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify_signature(digest_hex: str, signature_b64: str, public_key_b64der: str) -> None:
|
|
83
|
+
"""Offline verify a KMS ECDSA-P256 signature over the bundle's sha256 digest
|
|
84
|
+
(Decision C). No AWS call, no cross-account KMS. Raises ArtifactError on
|
|
85
|
+
failure. `cryptography` is imported lazily — only needed when verifying."""
|
|
86
|
+
import base64
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
from cryptography.hazmat.primitives import hashes
|
|
90
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
91
|
+
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
92
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
93
|
+
raise ArtifactError("signature verification requires the 'cryptography' package") from exc
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
pub = load_der_public_key(base64.b64decode(public_key_b64der))
|
|
97
|
+
pub.verify(
|
|
98
|
+
base64.b64decode(signature_b64),
|
|
99
|
+
bytes.fromhex(digest_hex),
|
|
100
|
+
ec.ECDSA(utils.Prehashed(hashes.SHA256())),
|
|
101
|
+
)
|
|
102
|
+
except Exception as exc: # invalid signature or key
|
|
103
|
+
raise ArtifactError(f"bundle signature verification failed: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cache_to_customer_s3(s3_client, bucket: str, module_id: str, version: str,
|
|
107
|
+
zip_path: Path) -> str:
|
|
108
|
+
"""Store the verified bundle so uninstall/update work without StrataHQ."""
|
|
109
|
+
key = f"artifacts/{module_id}/{version}/module.zip"
|
|
110
|
+
s3_client.upload_file(str(zip_path), bucket, key)
|
|
111
|
+
return f"s3://{bucket}/{key}"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""stratabi-module.json parsing + validation.
|
|
2
|
+
|
|
3
|
+
`required_permissions` drives least-privilege deploys (Decision A): the runner
|
|
4
|
+
builds an IAM session policy from it and assumes a deploy role scoped DOWN to
|
|
5
|
+
exactly what the module declares. Two accepted forms:
|
|
6
|
+
|
|
7
|
+
Structured (preferred — enables tight scoping; module authors MUST use this for
|
|
8
|
+
third-party modules):
|
|
9
|
+
"required_permissions": [
|
|
10
|
+
{"service": "s3",
|
|
11
|
+
"actions": ["s3:CreateBucket", "s3:PutObject", "s3:DeleteObject"],
|
|
12
|
+
"resources": ["arn:aws:s3:::acme-iceberg-*", "arn:aws:s3:::acme-iceberg-*/*"]}
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
Simple (legacy / Shaleio-internal only — results in a BROAD service:* grant):
|
|
16
|
+
"required_permissions": ["s3", "lambda", "dynamodb"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from strata.errors import ManifestError
|
|
27
|
+
|
|
28
|
+
VALID_ENTRYPOINTS = {"tofu"}
|
|
29
|
+
|
|
30
|
+
# Delivery mode governs whether the tofu bundle is persisted into the customer's
|
|
31
|
+
# environment. "local": the runner caches the verified bundle to the customer
|
|
32
|
+
# bucket, so teardown works offline/airgapped and without HQ. "managed": the
|
|
33
|
+
# bundle is never persisted; uninstall re-fetches it from HQ (destroy is always
|
|
34
|
+
# permitted, even for a lapsed/retired grant). Default local.
|
|
35
|
+
VALID_DELIVERY = {"local", "managed"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Manifest:
|
|
40
|
+
module_id: str
|
|
41
|
+
version: str
|
|
42
|
+
entrypoint: str
|
|
43
|
+
state_key: str
|
|
44
|
+
delivery: str = "local"
|
|
45
|
+
required_permissions: list[Any] = field(default_factory=list)
|
|
46
|
+
supports_destroy: bool = False
|
|
47
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def permissions_are_scoped(self) -> bool:
|
|
51
|
+
"""True when every permission entry is structured (service/actions/resources)."""
|
|
52
|
+
if not self.required_permissions:
|
|
53
|
+
return False
|
|
54
|
+
return all(isinstance(p, dict) for p in self.required_permissions)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_and_validate(path: Path) -> Manifest:
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
raise ManifestError(f"manifest not found: {path}")
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(path.read_text("utf-8"))
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise ManifestError(f"manifest is not valid JSON: {exc}") from exc
|
|
64
|
+
return validate(data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate(data: dict[str, Any]) -> Manifest:
|
|
68
|
+
for required in ("module_id", "version", "entrypoint", "state_key"):
|
|
69
|
+
if not data.get(required):
|
|
70
|
+
raise ManifestError(f"manifest missing required field '{required}'")
|
|
71
|
+
|
|
72
|
+
entrypoint = data["entrypoint"]
|
|
73
|
+
if entrypoint not in VALID_ENTRYPOINTS:
|
|
74
|
+
raise ManifestError(
|
|
75
|
+
f"unsupported entrypoint '{entrypoint}' (expected one of {sorted(VALID_ENTRYPOINTS)})"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
state_key = data["state_key"]
|
|
79
|
+
if state_key.startswith("/") or ".." in state_key:
|
|
80
|
+
raise ManifestError(f"unsafe state_key '{state_key}'")
|
|
81
|
+
|
|
82
|
+
delivery = data.get("delivery", "local")
|
|
83
|
+
if delivery not in VALID_DELIVERY:
|
|
84
|
+
raise ManifestError(
|
|
85
|
+
f"delivery must be one of {sorted(VALID_DELIVERY)}, got '{delivery}'"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
perms = data.get("required_permissions", [])
|
|
89
|
+
if not isinstance(perms, list):
|
|
90
|
+
raise ManifestError("required_permissions must be a list")
|
|
91
|
+
for entry in perms:
|
|
92
|
+
_validate_perm_entry(entry)
|
|
93
|
+
|
|
94
|
+
return Manifest(
|
|
95
|
+
module_id=data["module_id"],
|
|
96
|
+
version=str(data["version"]),
|
|
97
|
+
entrypoint=entrypoint,
|
|
98
|
+
state_key=state_key,
|
|
99
|
+
delivery=delivery,
|
|
100
|
+
required_permissions=perms,
|
|
101
|
+
supports_destroy=bool(data.get("supports_destroy", False)),
|
|
102
|
+
raw=data,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_perm_entry(entry: Any) -> None:
|
|
107
|
+
if isinstance(entry, str):
|
|
108
|
+
if not entry:
|
|
109
|
+
raise ManifestError("empty permission entry")
|
|
110
|
+
return
|
|
111
|
+
if isinstance(entry, dict):
|
|
112
|
+
if not entry.get("service"):
|
|
113
|
+
raise ManifestError("structured permission entry missing 'service'")
|
|
114
|
+
actions = entry.get("actions")
|
|
115
|
+
if not isinstance(actions, list) or not actions:
|
|
116
|
+
raise ManifestError(
|
|
117
|
+
f"permission entry for '{entry.get('service')}' needs a non-empty 'actions' list"
|
|
118
|
+
)
|
|
119
|
+
resources = entry.get("resources", ["*"])
|
|
120
|
+
if not isinstance(resources, list) or not resources:
|
|
121
|
+
raise ManifestError(
|
|
122
|
+
f"permission entry for '{entry.get('service')}' needs a non-empty 'resources' list"
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
raise ManifestError(f"invalid permission entry: {entry!r}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Resolve where a module artifact comes from.
|
|
2
|
+
|
|
3
|
+
A module spec can be:
|
|
4
|
+
* "iceberg-explorer" -> use the default source from config
|
|
5
|
+
* "stratahq/iceberg-explorer" -> force StrataHQ (entitlement required)
|
|
6
|
+
* "customer_s3/iceberg-explorer"-> force customer S3
|
|
7
|
+
* "s3://bucket/key/module.zip" -> explicit customer S3 object
|
|
8
|
+
* "https://.../module.zip" -> explicit PUBLIC artifact (no StrataHQ contact,
|
|
9
|
+
no entitlement) — e.g. a GitHub release of the
|
|
10
|
+
source-available Developer / Community edition.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from strata.config.models import Settings
|
|
19
|
+
|
|
20
|
+
KIND_STRATAHQ = "stratahq"
|
|
21
|
+
KIND_CUSTOMER = "customer_s3"
|
|
22
|
+
KIND_PUBLIC = "public"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SourceSpec:
|
|
27
|
+
kind: str # "stratahq" | "customer_s3" | "public"
|
|
28
|
+
module_id: str
|
|
29
|
+
customer_s3_uri: Optional[str] = None # set when kind == customer_s3 and explicit
|
|
30
|
+
public_url: Optional[str] = None # set when kind == public (http/https)
|
|
31
|
+
sha256: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _norm_module_id(name: str) -> str:
|
|
35
|
+
return name.replace("-", "_")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_source(module_spec: str, settings: Settings,
|
|
39
|
+
cli_source: Optional[str] = None) -> SourceSpec:
|
|
40
|
+
# Explicit http(s) URL is a PUBLIC artifact — fetched directly, no StrataHQ,
|
|
41
|
+
# no entitlement (used by the source-available dev/community edition).
|
|
42
|
+
if module_spec.startswith(("http://", "https://")):
|
|
43
|
+
tail = module_spec.split("/")[-1].split("?")[0]
|
|
44
|
+
module_id = _norm_module_id(tail.removesuffix(".zip"))
|
|
45
|
+
return SourceSpec(kind=KIND_PUBLIC, module_id=module_id, public_url=module_spec)
|
|
46
|
+
|
|
47
|
+
# Explicit s3:// URI is unambiguously a customer artifact.
|
|
48
|
+
if module_spec.startswith("s3://"):
|
|
49
|
+
tail = module_spec.split("/")[-1]
|
|
50
|
+
module_id = _norm_module_id(tail.removesuffix(".zip"))
|
|
51
|
+
return SourceSpec(kind=KIND_CUSTOMER, module_id=module_id, customer_s3_uri=module_spec)
|
|
52
|
+
|
|
53
|
+
kind = cli_source or settings.source
|
|
54
|
+
module_name = module_spec
|
|
55
|
+
if "/" in module_spec:
|
|
56
|
+
prefix, module_name = module_spec.split("/", 1)
|
|
57
|
+
if prefix in (KIND_STRATAHQ, KIND_CUSTOMER, KIND_PUBLIC):
|
|
58
|
+
kind = prefix
|
|
59
|
+
|
|
60
|
+
return SourceSpec(kind=kind, module_id=_norm_module_id(module_name))
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""boto3 session/clients for the TARGET (customer) account.
|
|
2
|
+
|
|
3
|
+
The operator's own AWS credentials drive everything here — Shaleio never holds
|
|
4
|
+
customer-account credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
|
|
14
|
+
from strata.config.models import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_session(settings: Settings) -> boto3.Session:
|
|
18
|
+
return boto3.Session(profile_name=settings.profile, region_name=settings.region)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=1)
|
|
22
|
+
def account_id(session: boto3.Session) -> str:
|
|
23
|
+
return session.client("sts").get_caller_identity()["Account"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def client(session: boto3.Session, service: str):
|
|
27
|
+
return session.client(service)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def caller_arn(session: boto3.Session) -> Optional[str]:
|
|
31
|
+
try:
|
|
32
|
+
return session.client("sts").get_caller_identity()["Arn"]
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Packaged StrataCI bootstrap infrastructure (immutable, version-matched).
|
|
2
|
+
|
|
3
|
+
The OpenTofu that stands StrataCI up inside a customer account ships as package
|
|
4
|
+
data under ``strata/bootstrap/infra/`` so a pipx-installed ``stratactl`` is fully
|
|
5
|
+
self-contained (no repo checkout needed). ``bootstrap_infra_files()`` returns the
|
|
6
|
+
packaged ``.tf`` files; the ``bootstrap`` command copies them into a per-account
|
|
7
|
+
working directory before running tofu — the packaged copy is never mutated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from importlib.abc import Traversable
|
|
14
|
+
|
|
15
|
+
__all__ = ["bootstrap_infra_root", "bootstrap_infra_files"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bootstrap_infra_root() -> Traversable:
|
|
19
|
+
"""The packaged bootstrap infra directory as an importlib.resources Traversable."""
|
|
20
|
+
return resources.files(__package__).joinpath("infra")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bootstrap_infra_files() -> list[str]:
|
|
24
|
+
"""Names of the packaged infra files (Terraform/OpenTofu sources + lock)."""
|
|
25
|
+
root = bootstrap_infra_root()
|
|
26
|
+
return sorted(
|
|
27
|
+
p.name for p in root.iterdir()
|
|
28
|
+
if p.name.endswith((".tf", ".tftpl", ".hcl")) or p.name.endswith(".example")
|
|
29
|
+
)
|