Skip to content

Commit 364177c

Browse files
authored
Merge pull request #236 from lsst-dm/u/tobyj/w_2026_04
DM-54101 : Allow Campaigns created by CLI to auto-start
2 parents 460f145 + 841606e commit 364177c

File tree

22 files changed

+265
-148
lines changed

22 files changed

+265
-148
lines changed

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ run: PGPORT=$(shell docker compose port postgresql 5432 | cut -d: -f2)
120120
run: export DB__URL=postgresql://cm-service@localhost:${PGPORT}/cm-service
121121
run: export DB__PASSWORD=INSECURE-PASSWORD
122122
run: export DB__ECHO=true
123+
run: export FEATURE_API_V2=1
124+
run: export FEATURE_API_V1=0
125+
run: export FEATURE_DAEMON_V2=0
123126
run: run-compose
124127
alembic upgrade head
125128
python3 -m lsst.cmservice.main
@@ -129,6 +132,12 @@ run-worker: PGPORT=$(shell docker compose port postgresql 5432 | cut -d: -f2)
129132
run-worker: export DB__URL=postgresql://cm-service@localhost:${PGPORT}/cm-service
130133
run-worker: export DB__PASSWORD=INSECURE-PASSWORD
131134
run-worker: export DB__ECHO=true
135+
run-worker: export FEATURE_API_V2=0
136+
run-worker: export FEATURE_API_V1=0
137+
run-worker: export FEATURE_DAEMON_V2=1
138+
run-worker: export FEATURE_DAEMON_CAMPAIGNS=1
139+
run-worker: export FEATURE_DAEMON_NODES=1
140+
run-worker: export FEATURE_ALLOW_TASK_UPSERT=1
132141
run-worker: run-compose
133142
alembic upgrade head
134143
python3 -m lsst.cmservice.daemon

alembic/seed/seed_ci_campaign.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ metadata:
1414
# information parameters for a specific manifest type here, but the campaign
1515
# comes last in the configuration lookup hierarchy!
1616
spec:
17-
# set the initial state of the campaign to "paused" so it can be step-advanced
18-
auto_transition: false
1917
butlerSelector:
2018
instrument: lsstcam
2119
embargo: "false"
Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,42 @@
1+
import re
12
from typing import Annotated, Literal
2-
from uuid import UUID, uuid5
3+
from uuid import uuid5
34

45
import typer
56

7+
from .models import TypedContext
68
from .settings import settings
79

810

9-
def preprocess_campaign_name(campaign: str) -> str:
10-
"""Preprocesses a campaign NAME by translating it to a campaign ID"""
11-
try:
12-
campaign_id = UUID(campaign)
13-
return str(campaign_id)
14-
except ValueError:
15-
return str(uuid5(settings.default_namespace, campaign))
11+
def as_snake_case(s: str) -> str:
12+
"""Preprocesses a string by sanitizing it and producing snake case."""
13+
# TODO clean up unicode characters and etc
14+
return re.sub(r"\W+?", "_", s)
1615

1716

18-
campaign_name = Annotated[str, typer.Argument()]
17+
def preprocess_campaign_name(ctx: TypedContext, campaign: str) -> str:
18+
"""Preprocesses a campaign NAME by translating it to a campaign ID and
19+
storing the result in the application context
20+
"""
21+
sanitized_campaign_name = as_snake_case(campaign)
22+
ctx.obj.campaign_name = sanitized_campaign_name
23+
ctx.obj.campaign_id = str(uuid5(settings.default_namespace, sanitized_campaign_name))
24+
return sanitized_campaign_name
25+
26+
27+
campaign_name = Annotated[
28+
str,
29+
typer.Argument(
30+
envvar="CM_CAMPAIGN",
31+
callback=preprocess_campaign_name,
32+
help="A campaign name that is coerced into a UUID, or a UUID.",
33+
),
34+
]
1935

20-
campaign_id = Annotated[str, typer.Argument(envvar="CM_CAMPAIGN", callback=preprocess_campaign_name)]
2136

2237
campaign_status = Annotated[
2338
Literal["paused", "rejected", "accepted", "failed"], typer.Argument(help="Campaign status name")
2439
]
2540

26-
node_id = Annotated[str, typer.Argument()]
41+
42+
node_id = Annotated[str, typer.Argument(help="An id for a node, as a UUID value.")]

packages/cm-commandline/src/lsst/cmservice/commandline/campaigns/app.py

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@
1818

1919
from .. import arguments, formatters
2020
from ..client import http_client
21+
from ..loader.app import load_from_yaml
22+
from ..models import TypedContext
2123

2224
app = typer.Typer()
2325
console = Console()
2426

2527

2628
@app.command()
27-
def list(ctx: typer.Context) -> None:
29+
def list(ctx: TypedContext) -> None:
2830
"""list all campaigns"""
29-
output_format = formatters.Formatters[ctx.obj.get("output_format")]
31+
output_format = formatters.Formatters[ctx.obj.output_format]
3032
with http_client(ctx) as session:
3133
try:
3234
r = session.get("/campaigns")
@@ -48,36 +50,26 @@ def list(ctx: typer.Context) -> None:
4850

4951
@app.command(name="new")
5052
def new_campaign(
51-
ctx: typer.Context,
53+
ctx: TypedContext,
5254
campaign: arguments.campaign_name,
53-
*,
54-
auto_transition: Annotated[
55-
bool,
56-
typer.Option(
57-
"--auto-transition/--no-auto-transition",
58-
help="Whether the new campaign should be handled by the Daemon or created in a paused state.",
59-
),
60-
] = True,
6155
) -> None:
62-
"""create a new empty campaign"""
63-
output_format = formatters.Formatters[ctx.obj.get("output_format")]
56+
"""Create a new empty campaign"""
57+
output_format = formatters.Formatters[ctx.obj.output_format]
6458
data = {
6559
"apiVersion": "io.lsst.cmservice/v1",
6660
"kind": "campaign",
6761
"metadata_": {
6862
"name": campaign,
6963
},
70-
"spec": {
71-
"auto_transition": auto_transition,
72-
},
64+
"spec": {},
7365
}
7466
with http_client(ctx) as session:
7567
r = session.post("/campaigns", json=data)
7668
r.raise_for_status()
7769

7870
match output_format:
7971
case formatters.Formatters.table:
80-
t = formatters.as_table(r.json())
72+
t = formatters.as_table([r.json()])
8173
Console().print(t)
8274
case formatters.Formatters.json:
8375
pretty = JSONHighlighter()
@@ -88,12 +80,12 @@ def new_campaign(
8880

8981
@app.command(name="describe")
9082
def describe_campaign(
91-
ctx: typer.Context,
92-
campaign: arguments.campaign_id,
83+
ctx: TypedContext,
84+
campaign: arguments.campaign_name,
9385
) -> None:
9486
"""describe a specific campaign"""
9587
with http_client(ctx) as session:
96-
r = session.get(f"/campaigns/{campaign}/summary")
88+
r = session.get(f"/campaigns/{ctx.obj.campaign_id}/summary")
9789
r.raise_for_status()
9890
# edges_url = r.headers["Edges"]
9991
# nodes_url = r.headers["Nodes"]
@@ -155,14 +147,14 @@ def describe_campaign(
155147

156148

157149
@app.command(name="start")
158-
def start_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
150+
def start_campaign(ctx: TypedContext, campaign: arguments.campaign_name) -> None:
159151
"""start a campaign"""
160152
data = {"status": "running"}
161153
status_update_url = None
162154

163155
with http_client(ctx) as session:
164156
r = session.patch(
165-
f"/campaigns/{campaign}",
157+
f"/campaigns/{ctx.obj.campaign_id}",
166158
json=data,
167159
headers={"Content-Type": "application/merge-patch+json"},
168160
)
@@ -205,8 +197,8 @@ def start_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
205197

206198
@app.command(name="set")
207199
def set_campaign_status(
208-
ctx: typer.Context,
209-
campaign: arguments.campaign_id,
200+
ctx: TypedContext,
201+
campaign: arguments.campaign_name,
210202
desired_state: arguments.campaign_status,
211203
*,
212204
force: Annotated[
@@ -223,7 +215,7 @@ def set_campaign_status(
223215

224216
with http_client(ctx) as session:
225217
r = session.patch(
226-
f"/campaigns/{campaign}",
218+
f"/campaigns/{ctx.obj.campaign_id}",
227219
json=data,
228220
headers={"Content-Type": "application/merge-patch+json"},
229221
)
@@ -264,15 +256,15 @@ def set_campaign_status(
264256

265257

266258
@app.command(name="advance")
267-
def advance_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
259+
def advance_campaign(ctx: TypedContext, campaign: arguments.campaign_name) -> None:
268260
"""advances a paused campaign"""
269261

270262
status_update_url = None
271263

272264
with http_client(ctx) as session:
273265
r = session.post(
274266
"/rpc/process",
275-
json={"campaign_id": campaign},
267+
json={"campaign_id": ctx.obj.campaign_id},
276268
headers={},
277269
)
278270
r.raise_for_status()
@@ -317,3 +309,30 @@ def advance_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> Non
317309
# )
318310

319311
# console.print(result_text)
312+
313+
314+
@app.command(name="load")
315+
def load_campaign(
316+
ctx: TypedContext,
317+
filename: Annotated[
318+
str,
319+
typer.Argument(help="A name or path to a YAML file containing a complete set of campaign manifests"),
320+
],
321+
campaign: arguments.campaign_name,
322+
*,
323+
auto_run: Annotated[
324+
bool,
325+
typer.Option(
326+
"--start",
327+
help="Start campaign automatically after loading",
328+
),
329+
] = False,
330+
) -> None:
331+
"""Loads a campaign from a YAML file, optionally starting it."""
332+
333+
ctx.invoke(load_from_yaml, ctx=ctx, filename=filename, campaign=campaign, strict=True)
334+
335+
# If argument "auto-start" is set, try to set campaign status after loading
336+
if auto_run:
337+
typer.echo(f"Starting campaign {ctx.obj.campaign_name}")
338+
ctx.invoke(start_campaign, ctx=ctx, campaign=campaign)

packages/cm-commandline/src/lsst/cmservice/commandline/cli.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .campaigns.app import app as campaigns_app
55
from .loader.app import app as loader_app
66
from .manifests.app import app as manifests_app
7+
from .models import AppContext, TypedContext
78
from .nodes.app import app as nodes_app
89
from .settings import settings
910

@@ -16,14 +17,12 @@
1617

1718
@app.callback()
1819
def build_context(
19-
ctx: typer.Context,
20+
ctx: TypedContext,
2021
output: options.output = "table",
2122
endpoint: options.endpoint = settings.endpoint,
2223
token: options.token = settings.token,
2324
) -> None:
24-
ctx.ensure_object(dict)
25-
ctx.obj["output_format"] = output
26-
ctx.obj["cm_endpoint_url"] = endpoint
25+
ctx.obj = AppContext(output_format=output, endpoint_url=endpoint, auth_token=token)
2726

2827

2928
if __name__ == "__main__":

packages/cm-commandline/src/lsst/cmservice/commandline/client.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,24 @@
44
from contextlib import contextmanager
55
from uuid import uuid4
66

7-
import typer
87
from httpx import Client, HTTPStatusError, HTTPTransport
98

109
from .logging import LOGGER
11-
from .settings import settings
10+
from .models import TypedContext
1211

1312
logger = LOGGER.bind(module=__name__)
1413

1514

1615
@contextmanager
17-
def http_client(ctx: typer.Context) -> Generator[Client]:
16+
def http_client(ctx: TypedContext) -> Generator[Client]:
1817
"""Generate a client session for cmclient API operations."""
1918
transport = HTTPTransport(
2019
verify=False,
2120
retries=3,
2221
)
23-
endpoint_url = ctx.obj.get("endpoint_url", settings.endpoint)
24-
api_version = ctx.obj.get("api_version", settings.api_version)
25-
auth_token = ctx.obj.get("auth_token", settings.token)
22+
endpoint_url = ctx.obj.endpoint_url
23+
api_version = ctx.obj.api_version
24+
auth_token = ctx.obj.auth_token
2625

2726
with Client(
2827
base_url=f"{endpoint_url}/{api_version}",

packages/cm-commandline/src/lsst/cmservice/commandline/loader/app.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,32 @@
55
from rich.console import Console
66
from rich.table import Table
77

8-
from .loader import load_selected_file
8+
from .. import arguments
9+
from ..models import TypedContext
10+
from .loader import StrictModeViolationError, load_selected_file
911

1012
app = typer.Typer()
1113

1214

1315
@app.command(name="yaml")
1416
def load_from_yaml(
15-
ctx: typer.Context,
17+
ctx: TypedContext,
1618
filename: str,
17-
campaign_name: Annotated[str | None, typer.Argument(envvar="CM_CAMPAIGN")] = None,
19+
campaign: arguments.campaign_name,
20+
*,
21+
strict: Annotated[
22+
bool, typer.Option(help="Load YAML in strict mode (file must have only a single campaign)")
23+
] = False,
1824
) -> None:
1925
"""Load manifests from a YAML file"""
20-
headers = load_selected_file(ctx, Path(filename), campaign_name)
26+
try:
27+
headers = load_selected_file(ctx, yaml_file=Path(filename), campaign=campaign, strict=strict)
28+
except StrictModeViolationError as e:
29+
typer.echo(e, err=True)
30+
raise typer.Exit(1)
2131

2232
if headers:
23-
table = Table(title=campaign_name)
33+
table = Table(title=ctx.obj.campaign_name)
2434
table.add_column("Link Name")
2535
table.add_column("Link")
2636

0 commit comments

Comments
 (0)