Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ authentication

### Functional Areas
* [Authentication](authentication) provides samples showing the various methods by which users can authenticate against the Visier platform.
* [Administration](administration) provides samples showing how to use Administration APIs for tenant configuration workflows (SDLC).
* [Data Intake](data-intake) provides samples showing how to use APIs to send data to your Visier tenant.

### Before You Begin
Expand Down
7 changes: 7 additions & 0 deletions administration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Administration

Samples that use Visier Administration APIs for tenant configuration workflows (SDLC).

## Samples

* [SDLC promotion sample](sdlc-promotion/python) shows a guarded Python workflow for promoting published configuration from a source tenant into a target tenant draft (SDLC), with publishing available only when explicitly requested.
5 changes: 5 additions & 0 deletions administration/sdlc-promotion/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
config.yaml
.env
68 changes: 68 additions & 0 deletions administration/sdlc-promotion/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Visier SDLC Promotion Sample

Sample Python workflow for promoting Visier configuration from a **source** tenant to a **target** tenant using Visier's public Administration APIs and the official `visier-platform-sdk`.

The guarded default is a preview: authenticate the source tenant, read published history, choose the export range, and show the target draft metadata that would be used. No ZIP export, target draft creation, import, or publish happens unless you explicitly request it.

## Requirements

- Python 3.9+
- Access to source and target tenants with the required Administration API capabilities

## Install

```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
```

Dependencies: `visier-platform-sdk`, `PyYAML`, and `python-dotenv`.

## Configure

1. Copy `config.yaml.example` to `config.yaml`.
2. Set `profiles.source` and `profiles.target` with each tenant's `base_url`, `api_key`, `username`, and `password`.
3. Prefer `${ENV_VAR}` placeholders in YAML and keep secrets in the environment or a `.env` file next to `config.yaml`.

The loader only reads `.env` from the config file's directory to avoid mixing credentials from another working directory. It rejects non-HTTPS base URLs and rejects configs where `source` and `target` clearly point to the same tenant.

## Promotion Modes

| Command | Effect |
| --- | --- |
| `python promote.py` | Preview only. Authenticates source, lists source history, chooses export ids, and prints planned target draft metadata. |
| `python promote.py --apply` | Applies target changes. Exports the ZIP, creates a target draft, and imports commits. Does not publish. |
| `python promote.py --apply --publish` | Applies target changes and runs `commitAndPublish`. This changes the target tenant's published configuration. |

`--publish` requires `--apply`. The old `--dry-run` flag is deprecated and now behaves like the default preview mode.

## Export Scope

| Flag | Meaning |
| --- | --- |
| `--mode latest` | Export only the most recent published version from the source. This is the default. |
| `--mode all` | Export full published history in one ZIP using a paginated source listing and an oldest-to-newest range. |
| `--config PATH` | Config YAML path. Defaults to `config.yaml` in the current working directory. |

## Workflow Steps

| Step | Tenant | What happens |
| --- | --- | --- |
| 0 | Source, and target only with `--apply` | Obtain a secure token through `visier-platform-sdk`. |
| 1 | Source | List published production versions with `GET /v1/admin/production-versions`. |
| 2 | Source | With `--apply`, export selected production versions as a ZIP. |
| 3 | Target | With `--apply`, create a new draft project. |
| 4 | Target | With `--apply`, upload the ZIP into the draft. |
| 5 | Target | With `--apply --publish`, commit and publish the draft. |

## Output And Logging

Normal CLI output is intentionally minimal: selected version ids, planned draft name/description, target draft id/name when created, ZIP size, and publish status. It does not print full API responses.

Set `VISIER_SDLC_DEBUG=1` only while troubleshooting draft metadata. Debug logging still avoids request credentials and full response dumps.

## API References

- [Production versions](https://docs.visier.com/developer/apis/administration/production-versions/code-samples.htm)
- [Projects](https://docs.visier.com/developer/apis/administration/projects/fundamentals/code-samples.htm)
28 changes: 28 additions & 0 deletions administration/sdlc-promotion/python/config.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copy to config.yaml and fill in real values. Never commit secrets.
#
# Profiles are named by *role* in the SDLC workflow (not your business environment names):
# source — tenant you export published work *from*
# target — tenant you import into and publish *to*
#
# Optional: use ${ENV_VAR} placeholders — values are read from the process
# environment or from a .env file in this config file's directory.
# The sample defaults to preview mode. Use --apply to create/import a target
# draft, and --apply --publish only when you are ready to publish target changes.

profiles:
source:
# e.g. https://<vanity>.api.visier.io (no trailing slash)
base_url: "https://your-source-vanity.api.visier.io"
api_key: "${VISIER_SOURCE_API_KEY}"
username: "${VISIER_SOURCE_USERNAME}"
password: "${VISIER_SOURCE_PASSWORD}"
# Optional — Visier header TargetTenantID (analytic tenant), if your setup requires it
# target_tenant_id: "WFF_j1r~c7o"
# Optional — if your tenant requires vanity on the token request
# vanity: "your-source-vanity"

target:
base_url: "https://your-target-vanity.api.visier.io"
api_key: "${VISIER_TARGET_API_KEY}"
username: "${VISIER_TARGET_USERNAME}"
password: "${VISIER_TARGET_PASSWORD}"
161 changes: 161 additions & 0 deletions administration/sdlc-promotion/python/promote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
CLI: load ``config.yaml`` and run a guarded SDLC promotion sample.

Usage (from this directory)::

pip install -r requirements.txt
source .venv/bin/activate # if you use a virtual environment
python promote.py # preview only; no target changes
python promote.py --apply # create/import target draft; do not publish
python promote.py --apply --publish

Troubleshooting draft project **name** vs API: set ``VISIER_SDLC_DEBUG=1`` and run again;
look for log lines tagged ``[SDLC troubleshooting]``. Unset the variable when done.
"""

from __future__ import annotations

import argparse
import logging
import sys

from visier_sdlc import (
NewDraftProject,
PromotionRequest,
VisierClient,
get_profile,
load_config,
run_sdlc_promotion,
)
from visier_sdlc.exceptions import VisierSDLCError


def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Run Visier SDLC promotion: export from the source tenant, import on the target tenant, "
"and publish only when explicitly requested."
),
)
parser.add_argument(
"--mode",
choices=("latest", "all"),
default="latest",
help=(
"What to export from the source tenant's published history: "
"'latest' = only the most recent published version; "
"'all' = full version history in one export (paginated list, oldest→newest range)."
),
)
parser.add_argument(
"--config",
default="config.yaml",
metavar="PATH",
help="Path to config YAML (default: config.yaml).",
)
parser.add_argument(
"--apply",
action="store_true",
help="Apply target changes: export the ZIP, create a target draft, and import commits. Does not publish.",
)
parser.add_argument(
"--publish",
action="store_true",
help="Commit and publish the imported target draft. Requires --apply.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help=argparse.SUPPRESS,
)
return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
if args.publish and not args.apply:
print("--publish requires --apply.", file=sys.stderr)
return 1
if args.dry_run:
print(
"--dry-run is deprecated and now behaves like the default preview mode. "
"Use --apply to create/import a draft, or --apply --publish to publish.",
file=sys.stderr,
)

try:
cfg = load_config(args.config)
except FileNotFoundError:
print(
f"Config not found: {args.config!r}\n"
"Copy config.yaml.example to config.yaml and add credentials, "
"or pass --config /path/to/config.yaml",
file=sys.stderr,
)
return 1
except (ImportError, KeyError, ValueError) as e:
print(f"Configuration error: {e}", file=sys.stderr)
return 1

try:
source = VisierClient(get_profile(cfg, "source"))
target = VisierClient(get_profile(cfg, "target"))
except (ImportError, KeyError, ValueError) as e:
print(f"Setup error: {e}", file=sys.stderr)
return 1

full_history = args.mode == "all"
# name/description are set in the workflow from source vanity + base URL (and, for
# --mode latest, the exported version name and id) when auto_draft_naming is set.
request = PromotionRequest(
new_project=NewDraftProject(
name="",
description="",
release_version="1",
ticket_number="",
version_number=0,
),
export_full_published_history=full_history,
auto_draft_naming=args.mode,
apply_to_target=args.apply,
publish=args.publish,
)
logging.info(
"Mode: %s (full_published_history=%s, apply=%s, publish=%s)",
args.mode,
full_history,
args.apply,
args.publish,
)

try:
result = run_sdlc_promotion(source, target, request)
except VisierSDLCError as e:
print(f"Aborted: {e}", file=sys.stderr)
return 2

print("Exported version ids:", result.export_start_version_id, result.export_end_version_id)
print("Planned target draft name:", result.planned_project.name)
print("Planned target draft description:", result.planned_project.description)
if result.new_project is None:
print("Preview only: no ZIP export, target draft creation, import, or publish was run.")
return 0

print("Export ZIP size bytes:", result.export_zip_size_bytes)
print("Target draft id:", result.new_project.get("id"))
print("Target draft name:", result.new_project.get("name"))
if result.publish_result is not None:
print("Publish (Step 5): completed.")
else:
print("Publish (Step 5): skipped. Inspect the target draft before publishing.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
6 changes: 6 additions & 0 deletions administration/sdlc-promotion/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Official Visier SDK used by this sample
visier-platform-sdk>=22222222.99201.2630
# Optional: load profiles from config.yaml
PyYAML>=6.0
# Optional: load secrets from a local .env file (see config.py)
python-dotenv>=1.0.0
100 changes: 100 additions & 0 deletions administration/sdlc-promotion/python/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import os
import tempfile
import unittest
import importlib.util
from pathlib import Path

from visier_sdlc.config import load_config


@unittest.skipUnless(importlib.util.find_spec("yaml"), "PyYAML is not installed")
class ConfigTests(unittest.TestCase):
def test_load_config_rejects_same_source_and_target(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text(
"""
profiles:
source:
base_url: "https://same.api.visier.io"
api_key: "key"
username: "user"
password: "pass"
target:
base_url: "https://same.api.visier.io"
api_key: "key"
username: "user"
password: "pass"
""",
encoding="utf-8",
)

with self.assertRaisesRegex(ValueError, "same tenant"):
load_config(path)

def test_load_config_requires_https(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text(
"""
profiles:
source:
base_url: "http://source.api.visier.io"
api_key: "key"
username: "user"
password: "pass"
target:
base_url: "https://target.api.visier.io"
api_key: "key"
username: "user"
password: "pass"
""",
encoding="utf-8",
)

with self.assertRaisesRegex(ValueError, "https"):
load_config(path)

@unittest.skipUnless(importlib.util.find_spec("dotenv"), "python-dotenv is not installed")
def test_env_file_loaded_only_from_config_directory(self) -> None:
with tempfile.TemporaryDirectory() as cwd, tempfile.TemporaryDirectory() as cfg_dir:
cwd_path = Path(cwd)
cfg_path = Path(cfg_dir)
(cwd_path / ".env").write_text("VISIER_SOURCE_PASSWORD=wrong\n", encoding="utf-8")
(cfg_path / ".env").write_text("VISIER_SOURCE_PASSWORD=right\n", encoding="utf-8")
config_file = cfg_path / "config.yaml"
config_file.write_text(
"""
profiles:
source:
base_url: "https://source.api.visier.io"
api_key: "key"
username: "user"
password: "${VISIER_SOURCE_PASSWORD}"
target:
base_url: "https://target.api.visier.io"
api_key: "key"
username: "user"
password: "pass"
""",
encoding="utf-8",
)
old_cwd = Path.cwd()
old_env = os.environ.pop("VISIER_SOURCE_PASSWORD", None)
try:
os.chdir(cwd_path)
cfg = load_config(config_file)
finally:
os.chdir(old_cwd)
if old_env is not None:
os.environ["VISIER_SOURCE_PASSWORD"] = old_env
else:
os.environ.pop("VISIER_SOURCE_PASSWORD", None)

self.assertEqual(cfg["source"].password, "right")


if __name__ == "__main__":
unittest.main()
Loading