Skip to content

Commit 1bf941b

Browse files
committed
needlectl: add --profile and --config-dir options, support prod/dev multi-file compose stacks, default profile prod
- Introduce Profile enum and --profile/-P CLI option (default prod) - Add --config-dir/-C flag to override config directory; defaults to NEEDLE_HOME/configs or profile-specific dev configs - main() callback sets NEEDLE_CONFIG_DIR and NEEDLE_COMPOSE_FILES env vars for profile-based compose stacks - DockerComposeManager now reads NEEDLE_COMPOSE_FILES and supports multiple -f args - Remove deprecated add_volume method - Update get_config_file to respect NEEDLE_CONFIG_DIR fallback - Update README to document new flags and default profile behavior
1 parent 8d6ea04 commit 1bf941b

File tree

4 files changed

+128
-48
lines changed

4 files changed

+128
-48
lines changed

needlectl/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,39 @@ Needlectl is a command-line tool designed to interact with **Needle**, a system
77

88
---
99

10+
## ⚙️ Profiles & Configuration
11+
12+
You can use the `--home`/`-H` flag to point needlectl at a custom Needle installation or local checkout, and select a runtime profile (`prod` or `dev`) that auto-configures the compose files and config directory. For advanced scenarios you can override just the configs path with `--config-dir`.
13+
14+
By default, `--profile` is set to **prod**, so omitting it will assume the production profile.
15+
16+
### Examples
17+
18+
#### Development
19+
20+
Spin up services against your local checkout in dev mode (fast configs + hot‑reload compose overrides):
21+
22+
```bash
23+
needlectl --home $(pwd) --profile dev service start
24+
```
25+
26+
This uses:
27+
- `NEEDLE_CONFIG_DIR=$NEEDLE_HOME/configs/fast`
28+
- Compose files: `docker/docker-compose.cpu.yaml` + `docker/docker-compose.dev.yaml`
29+
30+
#### Production
31+
32+
Run against a deployed install (standard configs + prod override):
33+
34+
```bash
35+
needlectl --home /opt/needle --profile prod service start
36+
```
37+
38+
#### Custom config-dir
39+
40+
Only override the configuration directory (compose files follow the selected profile):
41+
42+
```bash
43+
needlectl --home /opt/needle --config-dir /etc/needle/configs service start
44+
```
45+

needlectl/cli/main.py

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from typing import Optional
2+
from enum import Enum
3+
import os
4+
from pathlib import Path
25

36
import typer
47

8+
from utils import get_storage_dir
9+
510
from cli.directory import directory_app
611
from cli.generator import generator_app
712
from cli.query import query_app
@@ -34,21 +39,67 @@ def version_callback(value: bool):
3439
raise typer.Exit()
3540

3641

42+
class Profile(str, Enum):
43+
"""Named runtime profiles for choosing prod vs dev compose/config setup."""
44+
dev = "dev"
45+
prod = "prod"
46+
47+
3748
@app.callback()
3849
def main(
39-
ctx: typer.Context,
40-
api_url: str = typer.Option("http://127.0.0.1:8000", help="API URL of the backend service."),
41-
output: str = typer.Option("human", help="Output format: human|json|yaml"),
42-
version: Optional[bool] = typer.Option(
43-
None,
44-
"--version",
45-
"-v",
46-
help="Show the backend version and exit.",
47-
callback=version_callback,
48-
is_eager=True,
49-
),
50+
ctx: typer.Context,
51+
home: Optional[str] = typer.Option(
52+
None, "--home", "-H", help="Path to the Needle home directory"
53+
),
54+
profile: Profile = typer.Option(
55+
Profile.prod,
56+
"--profile",
57+
"-P",
58+
help="Runtime profile (prod or dev). Default: prod",
59+
case_sensitive=False,
60+
),
61+
config_dir: Optional[str] = typer.Option(
62+
None,
63+
"--config-dir",
64+
"-C",
65+
help="Override configuration directory (defaults to $NEEDLE_HOME/configs or profile-specific)",
66+
),
67+
api_url: str = typer.Option(
68+
"http://127.0.0.1:8000", help="API URL of the backend service."
69+
),
70+
output: str = typer.Option(
71+
"human", help="Output format: human|json|yaml"
72+
),
73+
version: Optional[bool] = typer.Option(
74+
None,
75+
"--version",
76+
"-v",
77+
help="Show the backend version and exit.",
78+
callback=version_callback,
79+
is_eager=True,
80+
),
5081
):
82+
if home:
83+
os.environ["NEEDLE_HOME"] = home
84+
85+
needle_home = get_storage_dir()
86+
87+
if config_dir:
88+
os.environ["NEEDLE_CONFIG_DIR"] = config_dir
89+
elif profile == Profile.dev:
90+
os.environ["NEEDLE_CONFIG_DIR"] = str(Path(needle_home) / "configs" / "dev")
91+
92+
files = []
93+
if profile == Profile.dev:
94+
files = [Path(needle_home) / "docker" / "docker-compose.cpu.yaml",
95+
Path(needle_home) / "docker" / "docker-compose.dev.yaml"]
96+
else:
97+
files = [Path(needle_home) / "docker" / "docker-compose.cpu.yaml",
98+
Path(needle_home) / "docker" / "docker-compose.prod.yaml"]
99+
os.environ["NEEDLE_COMPOSE_FILES"] = os.pathsep.join(str(p) for p in files)
100+
51101
ctx.obj = {
52102
"api_url": api_url,
53-
"output": output.lower()
103+
"output": output.lower(),
104+
"profile": profile,
54105
}

needlectl/docker/docker_compose_manager.py

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,32 @@
33
import json
44
import typer
55
import yaml
6+
from pathlib import Path
67
from utils import get_compose_file
78

89

910
class DockerComposeManager:
1011
def __init__(self):
11-
self.docker_compose_path = get_compose_file()
12-
if not os.path.isfile(self.docker_compose_path):
13-
typer.echo("Error: docker-compose file not found.")
14-
raise typer.Exit(code=1)
12+
compose_env = os.getenv("NEEDLE_COMPOSE_FILES")
13+
if compose_env:
14+
paths = compose_env.split(os.pathsep)
15+
self.compose_files = [Path(p) for p in paths]
16+
else:
17+
self.compose_files = [get_compose_file()]
18+
19+
for path in self.compose_files:
20+
if not path.is_file():
21+
typer.echo(f"Error: docker-compose file not found: {path}")
22+
raise typer.Exit(code=1)
1523

16-
# Load compose file to get service information
17-
with open(self.docker_compose_path, "r") as file:
24+
with open(self.compose_files[0], "r") as file:
1825
self.compose_data = yaml.safe_load(file)
1926

2027
def _docker_compose_run(self, *args):
21-
cmd = ["docker", "compose", "-f", self.docker_compose_path] + list(args)
28+
cmd = ["docker", "compose"]
29+
for path in self.compose_files:
30+
cmd += ["-f", str(path)]
31+
cmd += list(args)
2232
subprocess.run(cmd, check=True)
2333

2434
def get_backend_version(self) -> str:
@@ -39,7 +49,10 @@ def get_backend_version(self) -> str:
3949
return "unknown (backend service not found)"
4050

4151
# Get container ID of the running backend service
42-
cmd = ["docker", "compose", "-f", self.docker_compose_path, "ps", "-q", backend_service]
52+
cmd = ["docker", "compose"]
53+
for path in self.compose_files:
54+
cmd += ["-f", str(path)]
55+
cmd += ["ps", "-q", backend_service]
4356
container_id = subprocess.check_output(cmd).decode().strip()
4457

4558
if not container_id:
@@ -78,28 +91,3 @@ def restart_containers(self):
7891
def log_services(self, service_name):
7992
self._docker_compose_run("logs", service_name)
8093

81-
def add_volume(self, service_name, volume_path):
82-
"""
83-
Adds a volume to a specific service in the docker-compose file.
84-
Args:
85-
service_name (str): The name of the service to update.
86-
volume_path (str): The path of the volume to add.
87-
Raises:
88-
typer.Exit: If the service is not found in the docker-compose file.
89-
"""
90-
services = self.compose_data.get("services", {})
91-
if service_name not in services:
92-
typer.echo(f"Error: Service '{service_name}' not found in docker-compose.yml.")
93-
raise typer.Exit(code=1)
94-
95-
service = services[service_name]
96-
volumes = service.get("volumes", [])
97-
# Check if the volume already exists
98-
if volume_path not in [v.split(":")[0] for v in volumes if isinstance(v, str)]:
99-
volumes.append(f"{volume_path}:{volume_path}")
100-
service["volumes"] = volumes
101-
with open(self.docker_compose_path, "w") as file:
102-
yaml.safe_dump(self.compose_data, file, sort_keys=False)
103-
typer.echo(f"Added volume '{volume_path}' to service '{service_name}'.")
104-
else:
105-
typer.echo(f"Volume '{volume_path}' already exists for service '{service_name}'.")

needlectl/utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ def get_storage_dir():
1313

1414

1515
def get_config_file(filename) -> Path:
16-
configs_path = Path(os.path.join(get_storage_dir(), "configs"))
17-
if not os.path.exists(configs_path):
16+
"""Allow override of config directory via env var NEEDLE_CONFIG_DIR."""
17+
config_base = os.getenv("NEEDLE_CONFIG_DIR")
18+
if config_base:
19+
configs_path = Path(config_base)
20+
else:
21+
configs_path = Path(os.path.join(get_storage_dir(), "configs"))
22+
if not configs_path.exists():
1823
os.makedirs(configs_path, exist_ok=True)
1924

20-
return Path(os.path.join(configs_path, filename))
25+
return configs_path / filename
2126

2227

2328
def get_compose_file():

0 commit comments

Comments
 (0)