Skip to content

Commit c3c6d37

Browse files
Merge pull request #15 from Zipstack/feat/org-migration
UN-3479 [FEAT] SDK subpackage for org-to-org data migration
2 parents 9fcc4fc + 4779fb2 commit c3c6d37

43 files changed

Lines changed: 7286 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Lint and Test
2+
3+
on:
4+
pull_request:
5+
branches: [main, "feat/**", "fix/**"]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
lint-and-test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.11", "3.12"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{ matrix.python-version }}
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v6
25+
with:
26+
version: "0.6.14"
27+
enable-cache: true
28+
29+
- name: Install dependencies
30+
run: uv sync --dev --all-extras
31+
32+
- name: Create test env
33+
run: cp tests/sample.env tests/.env
34+
35+
- name: Tests (pytest)
36+
run: uv run pytest tests/ -v

‎pyproject.toml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ classifiers = [
2525
"Topic :: Software Development :: Libraries :: Python Modules",
2626
]
2727

28+
[project.optional-dependencies]
29+
clone = [
30+
"click>=8.1",
31+
"rich>=13.7",
32+
]
33+
34+
[project.scripts]
35+
unstract-clone = "unstract.clone.cli:main"
36+
2837
[build-system]
2938
requires = ["hatchling"]
3039
build-backend = "hatchling.build"

‎src/unstract/clone/README.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Cloning Organizations
2+
3+
> [!NOTE]
4+
> **Users are not cloned.** Two reasons:
5+
> - The same user may not need access in every environment.
6+
> - The same user may hold different roles across environments.
7+
>
8+
> **Groups _will_ be cloned** (upcoming — not yet implemented). Once available, an admin can add the right users to each group per environment.
9+
10+
Clone an Unstract organization's configured resources into another organization (same deployment or different). Useful for environment promotion (DEV → QA → PROD) and for spinning up a fresh org from a known-good baseline.
11+
12+
Cloned resources: adapters, connectors, custom tools, prompts, profiles, workflows, tool instances, workflow endpoints, tags, API deployments, pipelines, and Prompt Studio document files. The source org is left untouched.
13+
14+
> **Full documentation, behavior notes, CLI reference, and sample report:**
15+
> https://docs.unstract.com/unstract/unstract_platform/api_documentation/versions/cloning-orgs/
16+
17+
## Install
18+
19+
From a clone of this repository:
20+
21+
```bash
22+
uv sync --all-extras
23+
```
24+
25+
This pulls in the `clone` extra (`click`, `rich`) needed by the CLI.
26+
27+
## Quickstart
28+
29+
```bash
30+
UNSTRACT_SRC_PLATFORM_KEY=src_pk_... \
31+
UNSTRACT_TGT_PLATFORM_KEY=tgt_pk_... \
32+
uv run python -m unstract.clone clone \
33+
--source-url https://source.example.com \
34+
--source-org my-source-org \
35+
--target-url https://target.example.com \
36+
--target-org my-target-org
37+
```
38+
39+
Both keys must be **org admin Platform API keys**.
40+
41+
> [!WARNING]
42+
> Both keys grant broad access. Run from a trusted machine and rotate both keys after the clone completes.
43+
44+
> [!NOTE]
45+
> **Unstract Cloud free-trial adapters are not cloned.** Trial adapters are platform-owned and filtered out of the source listing. Prompt Studio projects whose default profile references them are skipped, and that cascades to dependent workflows, API deployments, and pipelines. Provision your own adapters on the target org and re-run the clone to bring the rest across.
46+
47+
> [!NOTE]
48+
> **OAuth-backed connectors need re-authorisation on target.** Connectors that use OAuth (e.g. Google Drive) are cloned without their refresh tokens — the Platform API never exposes them. Re-connect each one on the target after the clone.
49+
50+
## Re-runs are safe
51+
52+
If a phase fails partway, fix the cause and re-run the same command. Resources already on the target are detected by name and reused. There is no `--resume-from` flag — the target is the state.
53+
54+
## Files
55+
56+
The Prompt Studio document corpus is the only resource type with bytes on disk. Default cap per file is 25 MB; oversize files are reported for manual re-upload. Use `--skip-files` to skip bytes entirely (document records are still created).
57+
58+
> [!WARNING]
59+
> Run clones during low-activity windows. Concurrent uploads to the source org during a clone can create duplicate file records on the target.
60+
61+
See the [public docs](https://docs.unstract.com/unstract/unstract_platform/api_documentation/versions/cloning-orgs/) for the full flag list, behavioral notes, and the format of the end-of-run report.

‎src/unstract/clone/__init__.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Cloning organizations over the Platform API.
2+
3+
Migrates configured resources (adapters, connectors, custom tools, workflows,
4+
etc.) from one Unstract org to another using two admin-issued Platform API
5+
keys. The target deployment is the persistent state — re-runs reconcile
6+
against existing target rows by natural key.
7+
"""
8+
9+
from unstract.clone.context import (
10+
CloneContext,
11+
CloneOptions,
12+
OrgEndpoint,
13+
RemapTable,
14+
)
15+
from unstract.clone.orchestrator import clone
16+
from unstract.clone.report import CloneReport
17+
18+
__all__ = [
19+
"CloneContext",
20+
"CloneOptions",
21+
"CloneReport",
22+
"OrgEndpoint",
23+
"RemapTable",
24+
"clone",
25+
]

‎src/unstract/clone/__main__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Entry point: ``python -m unstract.clone``."""
2+
3+
from unstract.clone.cli import main
4+
5+
if __name__ == "__main__":
6+
main()

‎src/unstract/clone/cli.py‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Click-based CLI for ``unstract.clone``.
2+
3+
Single ``clone`` command. Platform keys can be passed via flags
4+
(``--source-key`` / ``--target-key``) or env vars
5+
(``UNSTRACT_SRC_PLATFORM_KEY`` / ``UNSTRACT_TGT_PLATFORM_KEY``) — env vars
6+
are preferred so the key never lands in shell history.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import logging
12+
import re
13+
import sys
14+
from typing import Any
15+
16+
import click
17+
18+
from unstract.clone.context import (
19+
DEFAULT_CONCURRENCY,
20+
DEFAULT_MAX_FILE_SIZE,
21+
CloneOptions,
22+
OrgEndpoint,
23+
)
24+
from unstract.clone.exceptions import CloneError
25+
from unstract.clone.orchestrator import clone as run_clone
26+
27+
_SIZE_UNITS: dict[str, int] = {
28+
"B": 1,
29+
"K": 1024,
30+
"KB": 1024,
31+
"M": 1024 * 1024,
32+
"MB": 1024 * 1024,
33+
"G": 1024 * 1024 * 1024,
34+
"GB": 1024 * 1024 * 1024,
35+
}
36+
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$")
37+
38+
39+
def _parse_size(value: str) -> int:
40+
"""Accept ``25``, ``25MB``, ``1.5GB`` etc. Returns bytes."""
41+
m = _SIZE_RE.match(value)
42+
if not m:
43+
raise click.BadParameter(f"can't parse size '{value}'")
44+
num, unit = m.group(1), m.group(2).upper() or "B"
45+
if unit not in _SIZE_UNITS:
46+
raise click.BadParameter(
47+
f"unknown size unit '{unit}'; use one of {sorted(_SIZE_UNITS)}"
48+
)
49+
return int(float(num) * _SIZE_UNITS[unit])
50+
51+
52+
def _configure_logging(verbose: bool) -> None:
53+
level = logging.DEBUG if verbose else logging.INFO
54+
logging.basicConfig(
55+
level=level,
56+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
57+
datefmt="%H:%M:%S",
58+
)
59+
60+
61+
def _split_csv(value: str | None) -> tuple[str, ...] | None:
62+
if not value:
63+
return None
64+
return tuple(p.strip() for p in value.split(",") if p.strip())
65+
66+
67+
@click.group()
68+
def cli() -> None:
69+
"""Cloning organizations over the Platform API."""
70+
71+
72+
@cli.command("clone")
73+
@click.option("--source-url", required=True, help="Base URL of the source deployment")
74+
@click.option(
75+
"--source-org", required=True, help="Source organization_id (slug in the URL path)"
76+
)
77+
@click.option(
78+
"--source-key",
79+
envvar="UNSTRACT_SRC_PLATFORM_KEY",
80+
required=True,
81+
help="Source admin's Platform API key (or env UNSTRACT_SRC_PLATFORM_KEY)",
82+
)
83+
@click.option("--target-url", required=True, help="Base URL of the target deployment")
84+
@click.option(
85+
"--target-org", required=True, help="Target organization_id (slug in the URL path)"
86+
)
87+
@click.option(
88+
"--target-key",
89+
envvar="UNSTRACT_TGT_PLATFORM_KEY",
90+
required=True,
91+
help="Target admin's Platform API key (or env UNSTRACT_TGT_PLATFORM_KEY)",
92+
)
93+
@click.option(
94+
"--dry-run", is_flag=True, help="Plan only — do not POST anything to target"
95+
)
96+
@click.option(
97+
"--include",
98+
default=None,
99+
help="Comma-separated phase names to include (default: all)",
100+
)
101+
@click.option(
102+
"--exclude",
103+
default=None,
104+
help="Comma-separated phase names to exclude",
105+
)
106+
@click.option(
107+
"--on-name-conflict",
108+
type=click.Choice(["adopt", "abort"]),
109+
default="adopt",
110+
show_default=True,
111+
help="What to do when a like-named entity exists in target",
112+
)
113+
@click.option(
114+
"--api-prefix",
115+
default="api/v1",
116+
show_default=True,
117+
help="Backend URL prefix (matches deployment's PATH_PREFIX env)",
118+
)
119+
@click.option(
120+
"--file-strategy",
121+
type=click.Choice(["platform_api", "skip"]),
122+
default="platform_api",
123+
show_default=True,
124+
help="How to move Prompt Studio document files. 'skip' = metadata only.",
125+
)
126+
@click.option(
127+
"--max-file-size",
128+
default="25MB",
129+
show_default=True,
130+
help="Per-file cap for the files phase. Oversize → reported, not aborted.",
131+
)
132+
@click.option(
133+
"--skip-files",
134+
is_flag=True,
135+
help="Alias for --file-strategy=skip.",
136+
)
137+
@click.option(
138+
"--concurrency",
139+
type=click.IntRange(min=1, max=32),
140+
default=DEFAULT_CONCURRENCY,
141+
show_default=True,
142+
help="Per-phase worker count. 1 = strictly sequential.",
143+
)
144+
@click.option("-v", "--verbose", is_flag=True, help="Debug logging")
145+
def clone_cmd(
146+
source_url: str,
147+
source_org: str,
148+
source_key: str,
149+
target_url: str,
150+
target_org: str,
151+
target_key: str,
152+
dry_run: bool,
153+
include: str | None,
154+
exclude: str | None,
155+
on_name_conflict: str,
156+
api_prefix: str,
157+
file_strategy: str,
158+
max_file_size: str,
159+
skip_files: bool,
160+
concurrency: int,
161+
verbose: bool,
162+
) -> None:
163+
"""Clone configured resources from one org to another."""
164+
_configure_logging(verbose)
165+
166+
effective_strategy = "skip" if skip_files else file_strategy
167+
try:
168+
cap_bytes = _parse_size(max_file_size)
169+
except click.BadParameter as e:
170+
raise click.UsageError(str(e)) from e
171+
172+
options = CloneOptions(
173+
dry_run=dry_run,
174+
include=_split_csv(include),
175+
exclude=_split_csv(exclude) or (),
176+
on_name_conflict=on_name_conflict,
177+
verbose=verbose,
178+
file_strategy=effective_strategy,
179+
max_file_size=cap_bytes if cap_bytes is not None else DEFAULT_MAX_FILE_SIZE,
180+
concurrency=concurrency,
181+
)
182+
183+
source = OrgEndpoint(
184+
base_url=source_url,
185+
organization_id=source_org,
186+
platform_key=source_key,
187+
api_path_prefix=api_prefix,
188+
)
189+
target = OrgEndpoint(
190+
base_url=target_url,
191+
organization_id=target_org,
192+
platform_key=target_key,
193+
api_path_prefix=api_prefix,
194+
)
195+
196+
try:
197+
report = run_clone(source, target, options)
198+
except CloneError as e:
199+
click.echo(f"Clone failed: {e}", err=True)
200+
sys.exit(2)
201+
202+
click.echo(report.render())
203+
if report.aborted or any(p.failed for p in report.phases):
204+
sys.exit(1)
205+
206+
207+
def main(argv: list[str] | None = None) -> Any:
208+
return cli(args=argv, standalone_mode=True)
209+
210+
211+
if __name__ == "__main__":
212+
main()

0 commit comments

Comments
 (0)