Skip to content

Commit afe791d

Browse files
authored
Merge pull request #110 from dbt-labs/feature/add-link-command
2 parents d6ff468 + 5be6edb commit afe791d

14 files changed

Lines changed: 258 additions & 44 deletions

File tree

README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,21 @@ Queries dbt Cloud and provide the YAML definition for those jobs. It includes th
9292

9393
- it is possible to restrict the list of dbt Cloud Job IDs by adding `... -j 101 -j 123 -j 234`
9494
- this command also accepts a list of project IDs or environments IDs to limit the command for: `dbt-jobs-as-code sync <config_file.yml> -p 1234 -p 2345 -e 4567 -e 5678`
95+
- this command accepts a `--include-linked-id` parameter to allow linking the jobs in the YAML to existing jobs in dbt Cloud, by renaming those
9596
- once the YAML has been retrieved, it is possible to copy/paste it in a local YAML file to create/update the local jobs definition.
9697

97-
To move some ui-jobs to jobs-as-code, perform the following steps:
98+
Once the configuration is imported, it is possible to "link" existing jobs by using the `link` command explained below.
9899

99-
- run the command to import the jobs
100-
- copy paste the job/jobs into a YAML file
101-
- change the `import_` id of the job in the YAML file to another unique identifier
102-
- rename the job in the UI to end with `[[new_job_identifier]]`
103-
- run a `plan` command to verify that no changes are required for the given job
100+
#### `link`
101+
102+
Command: `dbt-jobs-as-code link <config_file.yml>`
103+
104+
Links dbt Cloud jobs with the corresponding identifier from the YAML file by renaming the jobs, adding the `[[ ... ]]` part in the job name.
105+
106+
To do so, the program looks at the YAML file for the config `linked_id`.
107+
`linked_id` can be added manually or can be added automatically when calling `dbt-jobs-as-code import-jobs` with the `--include-linked-id` parameter.
108+
109+
Accepts a `--dry-run` flag to see what jobs would be changed, without actually changing them.
104110

105111
#### `unlink`
106112

@@ -161,14 +167,15 @@ The tool will raise errors if:
161167

162168
### Summary of parameters
163169

164-
| Command | `--project-id` / `-p` | `--environment-id` / `-e` | `--limit-projects-envs-to-yml` / `-l` | `--vars-yml` / `-v` | `--online` | `--job-id` / `-j` | `--identifier` / `-i` | `--dry-run` |
165-
| --------------- | :-------------------: | :-----------------------: | :-----------------------------------: | :-----------------: | :--------: | :---------------: | :-------------------: | :---------: |
166-
| plan | ✅ | ✅ | ✅ | ✅ | | | | |
167-
| sync | ✅ | ✅ | ✅ | ✅ | | | | |
168-
| validate | | | | ✅ | ✅ | | | |
169-
| import-jobs | ✅ | ✅ | | | | ✅ | | |
170-
| unlink | | | | | | | ✅ | ✅ |
171-
| deactivate-jobs | | | | | | ✅ | | |
170+
| Command | `--project-id` / `-p` | `--environment-id` / `-e` | `--limit-projects-envs-to-yml` / `-l` | `--vars-yml` / `-v` | `--online` | `--job-id` / `-j` | `--identifier` / `-i` | `--dry-run` | `--include-linked-id` |
171+
| --------------- | :-------------------: | :-----------------------: | :-----------------------------------: | :-----------------: | :--------: | :---------------: | :-------------------: | :---------: | :-------------------: |
172+
| plan | ✅ | ✅ | ✅ | ✅ | | | | | |
173+
| sync | ✅ | ✅ | ✅ | ✅ | | | | | |
174+
| validate | | | | ✅ | ✅ | | | | |
175+
| import-jobs | ✅ | ✅ | | | | ✅ | | | ✅ |
176+
| link | | | | | | | | ✅ | |
177+
| unlink | | | | | | | ✅ | ✅ | |
178+
| deactivate-jobs | | | | | | ✅ | | | |
172179

173180
As a reminder using `--project-id` and/or `--environment-id` is not compatible with using `--limit-projects-envs-to-yml`.
174181
We can only restricts by providing the IDs or by forcing to restrict on the environments and projects in the YML file.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ dependencies = [
2020
"importlib-metadata<7,>=6.0",
2121
]
2222
name = "dbt-jobs-as-code"
23-
version = "0.10.0"
23+
version = "0.11.0"
2424
description = "A CLI to allow defining dbt Cloud jobs as code"
2525
readme = "README.md"
2626
keywords = [

src/dbt_jobs_as_code/client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def delete_job(self, job: JobDefinition) -> None:
141141
else:
142142
logger.success("Job deleted successfully.")
143143

144-
def get_job(self, job_id: int) -> Optional[JobDefinition]:
144+
def get_job(self, job_id: int) -> JobDefinition:
145145
"""Generate a Job based on a dbt Cloud job."""
146146

147147
self._check_for_creds()
File renamed without changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
3+
4+
from dbt_jobs_as_code.client import DBTCloud, DBTCloudException
5+
from dbt_jobs_as_code.schemas.job import JobDefinition
6+
7+
8+
@dataclass
9+
class LinkableCheck:
10+
can_be_linked: bool
11+
message: str
12+
linked_job: Optional[JobDefinition] = None
13+
14+
15+
def can_be_linked(
16+
job_identifier: str, job_definition: JobDefinition, dbt_cloud: DBTCloud
17+
) -> LinkableCheck:
18+
if job_definition.linked_id is None:
19+
return LinkableCheck(
20+
False, f"Job '{job_identifier}' doesn't have an ID in YAML. It cannot be linked"
21+
)
22+
23+
try:
24+
cloud_job = dbt_cloud.get_job(job_id=job_definition.linked_id)
25+
except DBTCloudException as e:
26+
return LinkableCheck(
27+
False,
28+
f"Job {job_definition.linked_id} doesn't exist in dbt Cloud. It cannot be linked",
29+
)
30+
31+
if cloud_job.identifier is not None:
32+
return LinkableCheck(
33+
False,
34+
f"Job {job_definition.linked_id} is already linked with the identifier {cloud_job.identifier}. You should unlink it before if you want to link it to a new identifier.",
35+
)
36+
37+
return LinkableCheck(True, "", cloud_job)

src/dbt_jobs_as_code/exporter/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from dbt_jobs_as_code.schemas.job import JobDefinition
66

77

8-
def export_jobs_yml(jobs: list[JobDefinition]):
8+
def export_jobs_yml(jobs: list[JobDefinition], include_linked_id: bool = False):
99
"""Export a list of job definitions to YML"""
1010

1111
export_yml = {"jobs": {}}
1212
for id, cloud_job in enumerate(jobs):
13-
export_yml["jobs"][f"import_{id + 1}"] = cloud_job.to_load_format()
13+
export_yml["jobs"][f"import_{id + 1}"] = cloud_job.to_load_format(include_linked_id)
1414

1515
print(
1616
"# yaml-language-server: $schema=https://raw.githubusercontent.com/dbt-labs/dbt-jobs-as-code/main/src/dbt_jobs_as_code/schemas/load_job_schema.json"

src/dbt_jobs_as_code/main.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import os
22
import sys
3+
from importlib.metadata import version
34
from pathlib import Path
45

56
import click
67
from loguru import logger
78
from rich.console import Console
89

9-
from dbt_jobs_as_code.changeset.change_set import build_change_set
10-
from dbt_jobs_as_code.client import DBTCloud
10+
from dbt_jobs_as_code.client import DBTCloud, DBTCloudException
11+
from dbt_jobs_as_code.cloud_yaml_mapping.change_set import build_change_set
12+
from dbt_jobs_as_code.cloud_yaml_mapping.validate_link import LinkableCheck, can_be_linked
1113
from dbt_jobs_as_code.exporter.export import export_jobs_yml
1214
from dbt_jobs_as_code.loader.load import load_job_configuration
1315
from dbt_jobs_as_code.schemas.config import generate_config_schema
1416

17+
VERSION = version("dbt-jobs-as-code")
18+
1519
# adding the ability to disable ssl verification, useful for self-signed certificates and local testing
1620
option_disable_ssl_verification = click.option(
1721
"--disable-ssl-verification",
@@ -52,7 +56,11 @@
5256
)
5357

5458

55-
@click.group()
59+
@click.group(
60+
help=f"dbt-jobs-as-code {VERSION}\n\nA CLI to allow defining dbt Cloud jobs as code",
61+
context_settings={"max_content_width": 120},
62+
)
63+
@click.version_option(version=VERSION)
5664
def cli() -> None:
5765
pass
5866

@@ -277,6 +285,11 @@ def validate(config, vars_yml, online, disable_ssl_verification):
277285
help="Check if the job model has missing fields.",
278286
hidden=True,
279287
)
288+
@click.option(
289+
"--include-linked-id",
290+
is_flag=True,
291+
help="Include the job ID when exporting jobs.",
292+
)
280293
def import_jobs(
281294
config,
282295
account_id,
@@ -285,6 +298,7 @@ def import_jobs(
285298
job_id,
286299
disable_ssl_verification,
287300
check_missing_fields=False,
301+
include_linked_id=False,
288302
):
289303
"""
290304
Generate YML file for import.
@@ -354,7 +368,57 @@ def import_jobs(
354368
cloud_job.custom_environment_variables.append(env_var)
355369

356370
logger.success(f"YML file for the current dbt Cloud jobs")
357-
export_jobs_yml(cloud_jobs)
371+
export_jobs_yml(cloud_jobs, include_linked_id)
372+
373+
374+
@cli.command()
375+
@option_disable_ssl_verification
376+
@click.argument("config", type=click.File("r"))
377+
@click.option("--dry-run", is_flag=True, help="In dry run mode we don't update dbt Cloud.")
378+
def link(config, dry_run, disable_ssl_verification):
379+
"""
380+
Link the YML file to dbt Cloud by adding the identifier to the job name.
381+
All relevant jobs get the part [[...]] added to their name
382+
"""
383+
384+
yaml_jobs = load_job_configuration(config, None).jobs
385+
account_id = list(yaml_jobs.values())[0].account_id
386+
387+
dbt_cloud = DBTCloud(
388+
account_id=account_id,
389+
api_key=os.environ.get("DBT_API_KEY"),
390+
base_url=os.environ.get("DBT_BASE_URL", "https://cloud.getdbt.com"),
391+
disable_ssl_verification=disable_ssl_verification,
392+
)
393+
394+
some_jobs_updated = False
395+
for current_identifier, job_details in yaml_jobs.items():
396+
linkable_check = can_be_linked(current_identifier, job_details, dbt_cloud)
397+
if not linkable_check.can_be_linked:
398+
logger.error(linkable_check.message)
399+
continue
400+
401+
# impossible according to the check but needed to fix type checking
402+
assert linkable_check.linked_job is not None
403+
404+
cloud_job = linkable_check.linked_job
405+
cloud_job.identifier = current_identifier
406+
if dry_run:
407+
logger.info(
408+
f"Would link/rename the job {cloud_job.id}:{cloud_job.name} [[{current_identifier}]]"
409+
)
410+
else:
411+
logger.info(
412+
f"Linking/Renaming the job {cloud_job.id}:{cloud_job.name} [[{current_identifier}]]"
413+
)
414+
dbt_cloud.update_job(job=cloud_job)
415+
some_jobs_updated = True
416+
417+
if not dry_run:
418+
if some_jobs_updated:
419+
logger.success(f"Updated all jobs!")
420+
else:
421+
logger.info(f"No jobs to link")
358422

359423

360424
@cli.command()
@@ -379,15 +443,12 @@ def unlink(config, account_id, dry_run, identifier, disable_ssl_verification):
379443
if account_id:
380444
cloud_account_id = account_id
381445
elif config:
446+
# we get the account id from the config file
382447
defined_jobs = load_job_configuration(config, None).jobs.values()
383448
cloud_account_id = list(defined_jobs)[0].account_id
384449
else:
385450
raise click.BadParameter("Either --config or --account-id must be provided")
386451

387-
# we get the account id from the config file
388-
defined_jobs = load_job_configuration(config, None).jobs.values()
389-
cloud_account_id = list(defined_jobs)[0].account_id
390-
391452
dbt_cloud = DBTCloud(
392453
account_id=cloud_account_id,
393454
api_key=os.environ.get("DBT_API_KEY"),
@@ -407,7 +468,7 @@ def unlink(config, account_id, dry_run, identifier, disable_ssl_verification):
407468
cloud_job.identifier = None
408469
if dry_run:
409470
logger.info(
410-
f"Would unlink/rename the job {cloud_job.id}:{cloud_job.name} [[{current_identifier}]]"
471+
f"Would link/rename the job {cloud_job.id}:{cloud_job.name} [[{current_identifier}]]"
411472
)
412473
else:
413474
logger.info(

src/dbt_jobs_as_code/schemas/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def _job_to_dict(job: JobDefinition):
2222
exclude={
2323
"id", # we want to exclude id because our YAML file will not have it
2424
"custom_environment_variables", # TODO: Add this back in. Requires extra API calls.
25+
"linked_id", # we want to exclude linked_id because dbt Cloud doesn't save it
2526
}
2627
)
2728
return dict_vals

src/dbt_jobs_as_code/schemas/custom_environment_variable.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class CustomEnvironmentVariablePayload(CustomEnvironmentVariable):
2626
project_id: int
2727
account_id: int
2828
raw_value: Optional[str] = None
29-
value: Optional[str] = Field(None, exclude=True)
29+
value: Optional[str] = Field(default=None, exclude=True)
3030

3131
def __init__(self, **data: Any):
3232
data["raw_value"] = data["value"] if "value" in data else data["display_value"]

src/dbt_jobs_as_code/schemas/job.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@
1919
class JobDefinition(BaseModel):
2020
"""A definition for a dbt Cloud job."""
2121

22+
linked_id: Optional[int] = Field(
23+
default=None,
24+
description="The ID of the job in dbt Cloud that we want to link. Only used for the 'link' command.",
25+
)
2226
id: Optional[int] = None
23-
identifier: Optional[str] = None
27+
identifier: Optional[str] = Field(
28+
default=None,
29+
description="The internal job identifier for the job for dbt-jobs-as-code. Will be added at the end of the job name.",
30+
)
2431
account_id: int = field_mandatory_int_allowed_as_string_in_schema
2532
project_id: int = field_mandatory_int_allowed_as_string_in_schema
2633
environment_id: int = field_mandatory_int_allowed_as_string_in_schema
@@ -100,23 +107,28 @@ def to_payload(self):
100107
# otherwise, it means that we are "unlinking" the job from the job.yml
101108
if self.identifier:
102109
payload.name = f"{self.name} [[{self.identifier}]]"
103-
return payload.model_dump_json(exclude={"identifier", "custom_environment_variables"})
110+
return payload.model_dump_json(
111+
exclude={"linked_id", "identifier", "custom_environment_variables"}
112+
)
104113

105-
def to_load_format(self):
114+
def to_load_format(self, include_linked_id: bool = False):
106115
"""Generate a dict following our YML format to dump as YML later."""
107116

108-
data = self.model_dump(
109-
exclude={
110-
"identifier": True,
111-
"schedule": {
112-
"date": True,
113-
"time": True,
114-
},
115-
"custom_environment_variables": True,
116-
"id": True,
117-
"state": True,
118-
}
119-
)
117+
self.linked_id = self.id
118+
exclude_dict = {
119+
"identifier": True,
120+
"schedule": {
121+
"date": True,
122+
"time": True,
123+
},
124+
"id": True,
125+
"custom_environment_variables": True,
126+
"state": True,
127+
}
128+
if not include_linked_id:
129+
exclude_dict["linked_id"] = True
130+
131+
data = self.model_dump(exclude=exclude_dict)
120132
data["custom_environment_variables"] = []
121133
for env_var in self.custom_environment_variables:
122134
data["custom_environment_variables"].append({env_var.name: env_var.value})

0 commit comments

Comments
 (0)