quickscale-cli 0.87.0__tar.gz → 0.88.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.
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/PKG-INFO +2 -2
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/pyproject.toml +2 -2
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/_version.py +1 -1
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/apply_command.py +78 -30
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/deployment_commands.py +112 -17
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/development_commands.py +167 -75
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_commands.py +30 -24
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_config.py +8 -852
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_output.py +5 -1
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/status_command.py +234 -95
- quickscale_cli-0.88.0/src/quickscale_cli/utils/auth_migration.py +169 -0
- quickscale_cli-0.88.0/src/quickscale_cli/utils/docker_utils.py +688 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/module_dependency_sync.py +72 -6
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/module_wiring_manager.py +54 -28
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/project_manager.py +38 -22
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/railway_utils.py +2 -2
- quickscale_cli-0.87.0/src/quickscale_cli/utils/docker_utils.py +0 -340
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/README.md +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/__init__.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/__init__.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/apply_support.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/dr_commands.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/dr_output.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/plan_command.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/plan_selection.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/remove_command.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/remove_output.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/main.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/py.typed +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/__init__.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/dependency_utils.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/development_state.py +0 -0
- {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/project_identity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quickscale-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.88.0
|
|
4
4
|
Summary: Command-line interface for QuickScale Django project generator
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Keywords: django,cli,project-generator,saas
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
14
14
|
Classifier: Framework :: Django
|
|
15
15
|
Classifier: Framework :: Django :: 6.0
|
|
16
16
|
Requires-Dist: click (>=8.4.2,<9.0.0)
|
|
17
|
-
Requires-Dist: quickscale-core (>=0.
|
|
17
|
+
Requires-Dist: quickscale-core (>=0.88.0,<0.89.0)
|
|
18
18
|
Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
|
|
19
19
|
Project-URL: Homepage, https://github.com/Experto-AI/quickscale
|
|
20
20
|
Project-URL: Repository, https://github.com/Experto-AI/quickscale
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quickscale-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.88.0"
|
|
4
4
|
description = "Command-line interface for QuickScale Django project generator"
|
|
5
5
|
requires-python = ">=3.14,<3.15"
|
|
6
6
|
readme = "README.md"
|
|
@@ -31,7 +31,7 @@ packages = [{include = "quickscale_cli", from = "src"}]
|
|
|
31
31
|
[tool.poetry.dependencies]
|
|
32
32
|
python = ">=3.14,<3.15"
|
|
33
33
|
click = "^8.4.2"
|
|
34
|
-
quickscale-core = "^0.
|
|
34
|
+
quickscale-core = "^0.88.0"
|
|
35
35
|
|
|
36
36
|
[tool.poetry.group.dev.dependencies]
|
|
37
37
|
# Dev dependencies centralized in root pyproject.toml
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# Auto-generated by scripts/version_tool.sh
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.88.0"
|
{quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/apply_command.py
RENAMED
|
@@ -68,9 +68,12 @@ from quickscale_cli.commands.apply_support import (
|
|
|
68
68
|
_report_theme_preflight_error,
|
|
69
69
|
)
|
|
70
70
|
from quickscale_cli.commands.apply_support import _resolve_apply_raw_root # noqa: F401
|
|
71
|
-
from quickscale_cli.commands.module_commands import
|
|
72
|
-
from quickscale_cli.commands.module_config import (
|
|
71
|
+
from quickscale_cli.commands.module_commands import (
|
|
73
72
|
APPLY_MODULE_EXECUTION_MODE,
|
|
73
|
+
embed_module,
|
|
74
|
+
ModuleEmbedProvenance,
|
|
75
|
+
)
|
|
76
|
+
from quickscale_cli.commands.module_config import (
|
|
74
77
|
get_default_backups_config,
|
|
75
78
|
validate_backups_module_options,
|
|
76
79
|
)
|
|
@@ -139,6 +142,7 @@ from quickscale_core.utils.git_utils import (
|
|
|
139
142
|
GitError,
|
|
140
143
|
is_working_directory_clean,
|
|
141
144
|
resolve_remote_ref,
|
|
145
|
+
resolve_split_branch,
|
|
142
146
|
validate_module_name,
|
|
143
147
|
validate_tag_name,
|
|
144
148
|
)
|
|
@@ -513,6 +517,30 @@ def _parse_split_ref_overrides(split_refs: tuple[str, ...]) -> dict[str, str]:
|
|
|
513
517
|
return parsed
|
|
514
518
|
|
|
515
519
|
|
|
520
|
+
def _expand_split_refs_from_branches(ctx: ApplyContext) -> dict[str, str]:
|
|
521
|
+
"""
|
|
522
|
+
Map every module being embedded to its canonical ``splits/<name>-module`` branch.
|
|
523
|
+
|
|
524
|
+
This is the bulk form of ``--split-ref``. Every override in the pre-seal
|
|
525
|
+
verification flow is mechanically the module's own split branch, so deriving
|
|
526
|
+
them removes a dozen hand-typed arguments -- and with them the chance of
|
|
527
|
+
typing one wrong or omitting one, which the exact-coverage rule would reject
|
|
528
|
+
only after the operator had already retyped the whole line.
|
|
529
|
+
|
|
530
|
+
The result covers exactly the embed set by construction, so
|
|
531
|
+
:func:`_validate_split_ref_override_coverage` cannot fail on it. It is
|
|
532
|
+
recomputed against the refreshed context after the advisory lock is taken,
|
|
533
|
+
because the embed set itself can change there.
|
|
534
|
+
"""
|
|
535
|
+
try:
|
|
536
|
+
return {
|
|
537
|
+
module_name: resolve_split_branch(module_name)
|
|
538
|
+
for module_name in sorted(_module_names_to_embed(ctx))
|
|
539
|
+
}
|
|
540
|
+
except GitError as error:
|
|
541
|
+
raise click.UsageError(f"--split-refs-from-branches: {error}") from error
|
|
542
|
+
|
|
543
|
+
|
|
516
544
|
def _module_names_to_embed(ctx: ApplyContext) -> set[str]:
|
|
517
545
|
"""Return the exact module target set for this apply pass."""
|
|
518
546
|
if ctx.existing_state is None:
|
|
@@ -882,12 +910,7 @@ def _load_module_manifests(
|
|
|
882
910
|
"""Load manifests for all installed modules"""
|
|
883
911
|
manifests: dict[str, ModuleManifest] = {}
|
|
884
912
|
for module_name in module_names:
|
|
885
|
-
|
|
886
|
-
manifest = get_manifest_for_module(project_path, module_name, strict=strict)
|
|
887
|
-
except ManifestError as error:
|
|
888
|
-
if strict and "Manifest file not found:" not in str(error):
|
|
889
|
-
raise
|
|
890
|
-
manifest = None
|
|
913
|
+
manifest = get_manifest_for_module(project_path, module_name, strict=strict)
|
|
891
914
|
if manifest:
|
|
892
915
|
manifests[module_name] = manifest
|
|
893
916
|
return manifests
|
|
@@ -1898,16 +1921,18 @@ def _attempt_provenance_repair_if_needed(ctx: ApplyContext) -> None:
|
|
|
1898
1921
|
|
|
1899
1922
|
try:
|
|
1900
1923
|
resolved_sha = resolve_remote_ref(remote, branch)
|
|
1901
|
-
module_state.commit_sha = resolved_sha
|
|
1902
|
-
# Backfill the full provenance triple: also refresh embedded_at
|
|
1903
|
-
# to the repair timestamp and ensure version is populated from
|
|
1904
|
-
# the embedded manifest when available.
|
|
1905
|
-
module_state.embedded_at = repair_timestamp
|
|
1906
1924
|
manifest = get_manifest_for_module(ctx.output_path, module_name)
|
|
1925
|
+
repaired_version = module_state.version
|
|
1907
1926
|
if manifest is not None:
|
|
1908
1927
|
normalized_version = normalize_installed_version(manifest.version)
|
|
1909
1928
|
if normalized_version is not None:
|
|
1910
|
-
|
|
1929
|
+
repaired_version = normalized_version
|
|
1930
|
+
# Commit the complete repair only after every lookup succeeds.
|
|
1931
|
+
# An incomplete embedded module must not leave partially repaired
|
|
1932
|
+
# state that can later be persisted.
|
|
1933
|
+
module_state.commit_sha = resolved_sha
|
|
1934
|
+
module_state.embedded_at = repair_timestamp
|
|
1935
|
+
module_state.version = repaired_version
|
|
1911
1936
|
repaired_any = True
|
|
1912
1937
|
click.secho(f"✅ Repaired {module_name}: {resolved_sha[:8]}", fg="green")
|
|
1913
1938
|
except Exception as e:
|
|
@@ -2980,15 +3005,12 @@ def _regenerate_managed_wiring_for_apply(
|
|
|
2980
3005
|
delta: Any | None,
|
|
2981
3006
|
) -> tuple[bool, str]:
|
|
2982
3007
|
try:
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
if not desired_module_names:
|
|
2990
|
-
selected = []
|
|
2991
|
-
options = {m: c.options for m, c in qs_config.modules.items()}
|
|
3008
|
+
del module_names, existing_state, delta
|
|
3009
|
+
selected = sorted(qs_config.modules.keys())
|
|
3010
|
+
options = {
|
|
3011
|
+
module_name: dict(module_config.options or {})
|
|
3012
|
+
for module_name, module_config in qs_config.modules.items()
|
|
3013
|
+
}
|
|
2992
3014
|
return regenerate_managed_wiring(
|
|
2993
3015
|
output_path,
|
|
2994
3016
|
module_names=selected,
|
|
@@ -3183,8 +3205,8 @@ def _refresh_context_after_lock(ctx: ApplyContext) -> None:
|
|
|
3183
3205
|
list(merged_state.modules.keys()),
|
|
3184
3206
|
strict=True,
|
|
3185
3207
|
)
|
|
3186
|
-
except ManifestError:
|
|
3187
|
-
|
|
3208
|
+
except ManifestError as error:
|
|
3209
|
+
_abort_for_manifest_error(error, command_name="apply")
|
|
3188
3210
|
|
|
3189
3211
|
# SA7.4: re-validate required-module version constraints with
|
|
3190
3212
|
# fresh manifests so a concurrent module update is not missed.
|
|
@@ -3373,6 +3395,8 @@ def _execute_apply_steps(
|
|
|
3373
3395
|
no_modules: bool,
|
|
3374
3396
|
verbose_docker: bool = False,
|
|
3375
3397
|
split_ref_overrides: Mapping[str, str] | None = None,
|
|
3398
|
+
*,
|
|
3399
|
+
split_refs_from_branches: bool = False,
|
|
3376
3400
|
) -> None:
|
|
3377
3401
|
"""Execute the apply steps after confirmation."""
|
|
3378
3402
|
click.echo("\n" + "=" * 50)
|
|
@@ -3412,6 +3436,11 @@ def _execute_apply_steps(
|
|
|
3412
3436
|
# the lock so that planning uses fresh state, not a stale snapshot
|
|
3413
3437
|
# taken before the lock was held.
|
|
3414
3438
|
_refresh_context_after_lock(ctx)
|
|
3439
|
+
# The embed set can change under the lock, so a set derived before it
|
|
3440
|
+
# would no longer cover exactly the modules being embedded. Re-derive
|
|
3441
|
+
# rather than re-validating a stale map.
|
|
3442
|
+
if split_refs_from_branches:
|
|
3443
|
+
split_ref_overrides = _expand_split_refs_from_branches(ctx)
|
|
3415
3444
|
_validate_split_ref_override_coverage(
|
|
3416
3445
|
ctx,
|
|
3417
3446
|
split_ref_overrides,
|
|
@@ -3589,11 +3618,14 @@ def _execute_apply_steps_locked(
|
|
|
3589
3618
|
# the full post-embed module set before continuing to wiring, dependency
|
|
3590
3619
|
# sync, or migration steps.
|
|
3591
3620
|
if embedded_modules:
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3621
|
+
try:
|
|
3622
|
+
post_embed_manifests = _load_module_manifests(
|
|
3623
|
+
ctx.output_path,
|
|
3624
|
+
list(ctx.qs_config.modules.keys()),
|
|
3625
|
+
strict=True,
|
|
3626
|
+
)
|
|
3627
|
+
except ManifestError as error:
|
|
3628
|
+
_abort_for_manifest_error(error, command_name="apply")
|
|
3597
3629
|
try:
|
|
3598
3630
|
check_required_module_versions(post_embed_manifests)
|
|
3599
3631
|
except ManifestError as error:
|
|
@@ -3973,6 +4005,14 @@ def _resolve_apply_preflight(config_path: Path) -> Path:
|
|
|
3973
4005
|
multiple=True,
|
|
3974
4006
|
help="Use an explicit split ref for each module being embedded (repeatable).",
|
|
3975
4007
|
)
|
|
4008
|
+
@click.option(
|
|
4009
|
+
"--split-refs-from-branches",
|
|
4010
|
+
is_flag=True,
|
|
4011
|
+
help=(
|
|
4012
|
+
"Embed every module from its own splits/<module>-module branch. "
|
|
4013
|
+
"Bulk form of --split-ref for pre-seal release verification."
|
|
4014
|
+
),
|
|
4015
|
+
)
|
|
3976
4016
|
@click.option(
|
|
3977
4017
|
"--verbose-docker",
|
|
3978
4018
|
is_flag=True,
|
|
@@ -3984,6 +4024,7 @@ def apply(
|
|
|
3984
4024
|
no_docker: bool,
|
|
3985
4025
|
no_modules: bool,
|
|
3986
4026
|
split_refs: tuple[str, ...],
|
|
4027
|
+
split_refs_from_branches: bool,
|
|
3987
4028
|
verbose_docker: bool,
|
|
3988
4029
|
) -> None:
|
|
3989
4030
|
"""
|
|
@@ -4026,12 +4067,18 @@ def apply(
|
|
|
4026
4067
|
15. Finalize authoritative state
|
|
4027
4068
|
16. Display next steps
|
|
4028
4069
|
"""
|
|
4070
|
+
if split_refs and split_refs_from_branches:
|
|
4071
|
+
raise click.UsageError(
|
|
4072
|
+
"--split-refs-from-branches cannot be combined with --split-ref"
|
|
4073
|
+
)
|
|
4029
4074
|
split_ref_overrides = _parse_split_ref_overrides(split_refs)
|
|
4030
4075
|
config_path = Path(config)
|
|
4031
4076
|
_resolve_apply_preflight(config_path)
|
|
4032
4077
|
|
|
4033
4078
|
# Prepare context
|
|
4034
4079
|
ctx = _prepare_apply_context(config_path)
|
|
4080
|
+
if split_refs_from_branches:
|
|
4081
|
+
split_ref_overrides = _expand_split_refs_from_branches(ctx)
|
|
4035
4082
|
_validate_split_ref_override_coverage(
|
|
4036
4083
|
ctx,
|
|
4037
4084
|
split_ref_overrides,
|
|
@@ -4071,4 +4118,5 @@ def apply(
|
|
|
4071
4118
|
no_modules,
|
|
4072
4119
|
show_docker_output,
|
|
4073
4120
|
split_ref_overrides,
|
|
4121
|
+
split_refs_from_branches=split_refs_from_branches,
|
|
4074
4122
|
)
|
{quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/deployment_commands.py
RENAMED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Deployment commands for production platforms."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
3
5
|
import sys
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
@@ -32,6 +34,23 @@ from quickscale_cli.utils.railway_utils import (
|
|
|
32
34
|
)
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
RUNTIME_DB_ROLE = "quickscale_runtime"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def runtime_database_url(postgres_service: str, password: str) -> str:
|
|
41
|
+
"""Build RUNTIME_DATABASE_URL for the restricted runtime role.
|
|
42
|
+
|
|
43
|
+
Host, port and database are Railway reference variables on the PostgreSQL
|
|
44
|
+
service, resolved by Railway at deploy time. The generated start.sh creates
|
|
45
|
+
the role from RUNTIME_DB_ROLE and RUNTIME_DB_PASSWORD before migrating.
|
|
46
|
+
"""
|
|
47
|
+
ref = postgres_service
|
|
48
|
+
return (
|
|
49
|
+
f"postgresql://{RUNTIME_DB_ROLE}:{password}"
|
|
50
|
+
f"@${{{{{ref}.PGHOST}}}}:${{{{{ref}.PGPORT}}}}/${{{{{ref}.PGDATABASE}}}}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
35
54
|
@click.group()
|
|
36
55
|
def deploy() -> None:
|
|
37
56
|
"""Deployment commands for production platforms."""
|
|
@@ -225,13 +244,30 @@ def _init_railway_project_step() -> None:
|
|
|
225
244
|
click.echo("✅ Railway project already initialized")
|
|
226
245
|
|
|
227
246
|
|
|
228
|
-
def
|
|
229
|
-
"""
|
|
247
|
+
def _list_railway_service_names() -> list[str]:
|
|
248
|
+
"""Return the service names in the linked Railway environment.
|
|
249
|
+
|
|
250
|
+
Uses ``railway service list --json``. A bare ``railway service`` is the
|
|
251
|
+
deprecated interactive link prompt: once a project has services it waits
|
|
252
|
+
for a selection and only ends at the command timeout.
|
|
253
|
+
"""
|
|
254
|
+
result = run_railway_command(["service", "list", "--json"], timeout=30)
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
f"railway service list failed: {(result.stderr or result.stdout).strip()}"
|
|
258
|
+
)
|
|
259
|
+
return [str(service.get("name", "")) for service in json.loads(result.stdout)]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _setup_postgres_step() -> str:
|
|
263
|
+
"""Set up PostgreSQL database service and return its service name."""
|
|
230
264
|
try:
|
|
231
|
-
|
|
265
|
+
names = _list_railway_service_names()
|
|
232
266
|
|
|
233
|
-
if "postgres" in
|
|
267
|
+
existing = [name for name in names if "postgres" in name.lower()]
|
|
268
|
+
if existing:
|
|
234
269
|
click.echo("✅ PostgreSQL service already exists")
|
|
270
|
+
return existing[0]
|
|
235
271
|
else:
|
|
236
272
|
click.echo("Adding PostgreSQL service...")
|
|
237
273
|
result = run_railway_command(["add", "--database", "postgres"], timeout=30)
|
|
@@ -243,24 +279,31 @@ def _setup_postgres_step() -> None:
|
|
|
243
279
|
click.secho("✅ PostgreSQL service added", fg="green")
|
|
244
280
|
except Exception as e:
|
|
245
281
|
click.secho(f"⚠️ Warning: Could not check database service: {e}", fg="yellow")
|
|
282
|
+
return "Postgres"
|
|
246
283
|
|
|
247
284
|
|
|
248
285
|
def _create_app_service_step(app_service: str) -> None:
|
|
249
286
|
"""Create application service."""
|
|
250
287
|
try:
|
|
251
|
-
|
|
288
|
+
names = _list_railway_service_names()
|
|
252
289
|
|
|
253
|
-
if app_service.lower() in
|
|
290
|
+
if app_service.lower() in (name.lower() for name in names):
|
|
254
291
|
click.echo(f"✅ Service '{app_service}' already exists")
|
|
255
292
|
else:
|
|
293
|
+
# Railway CLI 5.x prompts ("What do you need?", "Enter a variable")
|
|
294
|
+
# whenever it sees a TTY, even with --service, and its TUI leaves the
|
|
295
|
+
# terminal in raw mode on timeout. Run without a TTY so it never prompts.
|
|
256
296
|
result = run_railway_command(
|
|
257
|
-
["add", "--service", app_service], timeout=
|
|
297
|
+
["add", "--service", app_service], timeout=60, input_data=""
|
|
258
298
|
)
|
|
259
299
|
|
|
260
300
|
if result.returncode != 0:
|
|
261
301
|
click.secho(
|
|
262
302
|
f"⚠️ Warning: Could not create service '{app_service}'", fg="yellow"
|
|
263
303
|
)
|
|
304
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
305
|
+
if detail:
|
|
306
|
+
click.echo(f" {detail}", err=True)
|
|
264
307
|
click.echo("💡 Create manually: railway add --service", err=True)
|
|
265
308
|
else:
|
|
266
309
|
click.secho(f"✅ Service '{app_service}' created", fg="green")
|
|
@@ -283,11 +326,11 @@ def _link_database_step(app_service: str) -> bool:
|
|
|
283
326
|
click.secho(f"⚠️ Warning: {link_message}", fg="yellow")
|
|
284
327
|
click.echo("💡 You may need to link DATABASE_URL manually:")
|
|
285
328
|
click.echo(
|
|
286
|
-
f" railway
|
|
329
|
+
f" railway variable set 'DATABASE_URL=${{{{Postgres.DATABASE_URL}}}}' "
|
|
287
330
|
f"--service {app_service}"
|
|
288
331
|
)
|
|
289
332
|
click.echo(
|
|
290
|
-
f" railway
|
|
333
|
+
f" railway variable set 'DATABASE_URL=${{{{PostgreSQL.DATABASE_URL}}}}' "
|
|
291
334
|
f"--service {app_service}"
|
|
292
335
|
)
|
|
293
336
|
click.echo(" Use single quotes to prevent shell expansion errors")
|
|
@@ -311,12 +354,46 @@ def _generate_domain_step(app_service: str) -> str | None:
|
|
|
311
354
|
else:
|
|
312
355
|
click.secho("⚠️ Warning: Could not auto-generate domain", fg="yellow")
|
|
313
356
|
click.echo("💡 Generate manually: railway domain")
|
|
314
|
-
click.echo("💡 Then set: railway
|
|
357
|
+
click.echo("💡 Then set: railway variable set ALLOWED_HOSTS=<your-domain>")
|
|
315
358
|
return None
|
|
316
359
|
|
|
317
360
|
|
|
318
|
-
def
|
|
319
|
-
"""
|
|
361
|
+
def _existing_service_variables(app_service: str) -> dict[str, str]:
|
|
362
|
+
"""Return the app service's current Railway variables, or {} if unreadable."""
|
|
363
|
+
try:
|
|
364
|
+
return get_railway_variables(app_service) or {}
|
|
365
|
+
except Exception:
|
|
366
|
+
return {}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _runtime_role_variables(
|
|
370
|
+
postgres_service: str, existing: dict[str, str]
|
|
371
|
+
) -> dict[str, str]:
|
|
372
|
+
"""Return the runtime-role variables to set, honouring what already exists.
|
|
373
|
+
|
|
374
|
+
- ``RUNTIME_DATABASE_URL`` set without ``RUNTIME_DB_PASSWORD``: the role is
|
|
375
|
+
operator-managed, so nothing is written.
|
|
376
|
+
- ``RUNTIME_DB_PASSWORD`` already set: it is reused, so a re-deploy never
|
|
377
|
+
rotates the password underneath the container still serving traffic.
|
|
378
|
+
- Otherwise a new URL-safe (hex) password is generated.
|
|
379
|
+
"""
|
|
380
|
+
if existing.get("RUNTIME_DATABASE_URL") and not existing.get("RUNTIME_DB_PASSWORD"):
|
|
381
|
+
return {}
|
|
382
|
+
password = existing.get("RUNTIME_DB_PASSWORD") or secrets.token_hex(24)
|
|
383
|
+
return {
|
|
384
|
+
"RUNTIME_DB_ROLE": RUNTIME_DB_ROLE,
|
|
385
|
+
"RUNTIME_DB_PASSWORD": password,
|
|
386
|
+
"RUNTIME_DATABASE_URL": runtime_database_url(postgres_service, password),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _configure_env_vars_step(
|
|
391
|
+
app_service: str,
|
|
392
|
+
domain_name: str | None,
|
|
393
|
+
postgres_service: str = "Postgres",
|
|
394
|
+
existing_vars: dict[str, str] | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Configure environment variables in batch, including the runtime DB role."""
|
|
320
397
|
click.echo(f"Setting variables for service: {app_service}")
|
|
321
398
|
click.echo("💡 Setting all variables at once to minimize deployments")
|
|
322
399
|
|
|
@@ -329,6 +406,9 @@ def _configure_env_vars_step(app_service: str, domain_name: str | None) -> None:
|
|
|
329
406
|
"DJANGO_SETTINGS_MODULE": f"{django_package}.settings.production",
|
|
330
407
|
}
|
|
331
408
|
|
|
409
|
+
runtime_vars = _runtime_role_variables(postgres_service, existing_vars or {})
|
|
410
|
+
env_vars.update(runtime_vars)
|
|
411
|
+
|
|
332
412
|
if domain_name:
|
|
333
413
|
env_vars["ALLOWED_HOSTS"] = domain_name
|
|
334
414
|
|
|
@@ -337,10 +417,16 @@ def _configure_env_vars_step(app_service: str, domain_name: str | None) -> None:
|
|
|
337
417
|
if success:
|
|
338
418
|
click.secho("✅ All environment variables configured successfully", fg="green")
|
|
339
419
|
for key in env_vars:
|
|
340
|
-
if key
|
|
420
|
+
if key in ("SECRET_KEY", "RUNTIME_DB_PASSWORD"):
|
|
341
421
|
click.echo(f" • {key}=<generated>")
|
|
422
|
+
elif key == "RUNTIME_DATABASE_URL":
|
|
423
|
+
click.echo(f" • {key}=<{RUNTIME_DB_ROLE} @ {postgres_service}>")
|
|
342
424
|
else:
|
|
343
425
|
click.echo(f" • {key}={env_vars[key]}")
|
|
426
|
+
if not runtime_vars:
|
|
427
|
+
click.echo(
|
|
428
|
+
" • RUNTIME_DATABASE_URL: operator-managed (RUNTIME_DB_PASSWORD unset), left unchanged"
|
|
429
|
+
)
|
|
344
430
|
click.echo(
|
|
345
431
|
"💡 Variables are set without triggering a deployment. The deploy step below will start one."
|
|
346
432
|
)
|
|
@@ -487,7 +573,7 @@ def _display_summary(
|
|
|
487
573
|
"dashboard — start.sh will create it on next deploy"
|
|
488
574
|
)
|
|
489
575
|
click.echo(
|
|
490
|
-
f" Or manually: railway
|
|
576
|
+
f" Or manually: railway ssh --service {app_service} "
|
|
491
577
|
f"python manage.py createsuperuser"
|
|
492
578
|
)
|
|
493
579
|
click.echo(" 5. Configure custom domain (optional): railway domain")
|
|
@@ -497,11 +583,15 @@ def _display_summary(
|
|
|
497
583
|
click.echo(" • Verify DATABASE_URL in Railway dashboard > Variables")
|
|
498
584
|
if not database_linked:
|
|
499
585
|
click.echo(
|
|
500
|
-
" • Link DB manually: railway
|
|
586
|
+
" • Link DB manually: railway variable set "
|
|
501
587
|
f"'DATABASE_URL=${{{{Postgres.DATABASE_URL}}}}' --service {app_service}"
|
|
502
588
|
)
|
|
503
589
|
click.echo(" • Check healthcheck status in Railway dashboard")
|
|
504
590
|
click.echo(" • Migrations run automatically on first deploy")
|
|
591
|
+
click.echo(
|
|
592
|
+
f" • start.sh creates the restricted '{RUNTIME_DB_ROLE}' database role "
|
|
593
|
+
"before migrating"
|
|
594
|
+
)
|
|
505
595
|
|
|
506
596
|
click.echo("\n📖 Documentation:")
|
|
507
597
|
click.echo(" • Railway: https://docs.railway.app")
|
|
@@ -552,7 +642,7 @@ def railway(project_name: str | None) -> None:
|
|
|
552
642
|
|
|
553
643
|
# Step 4: Check for PostgreSQL service
|
|
554
644
|
click.echo("\n🗄️ Setting up PostgreSQL database...")
|
|
555
|
-
_setup_postgres_step()
|
|
645
|
+
postgres_service = _setup_postgres_step()
|
|
556
646
|
|
|
557
647
|
# Step 5: Create app service
|
|
558
648
|
click.echo(f"\n📦 Creating application service: {app_service}...")
|
|
@@ -569,7 +659,12 @@ def railway(project_name: str | None) -> None:
|
|
|
569
659
|
|
|
570
660
|
# Step 8: Configure environment variables in batch
|
|
571
661
|
click.echo("\n⚙️ Configuring environment variables...")
|
|
572
|
-
_configure_env_vars_step(
|
|
662
|
+
_configure_env_vars_step(
|
|
663
|
+
app_service,
|
|
664
|
+
domain_name,
|
|
665
|
+
postgres_service,
|
|
666
|
+
_existing_service_variables(app_service),
|
|
667
|
+
)
|
|
573
668
|
|
|
574
669
|
# Step 9: Deploy to app service
|
|
575
670
|
click.echo("\n🚢 Deploying application...")
|