Skip to content

Commit 2b3e149

Browse files
committed
octavia-cli: implement apply command
1 parent f71754d commit 2b3e149

File tree

16 files changed

+1419
-52
lines changed

16 files changed

+1419
-52
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
from glob import glob
6+
from typing import List, Tuple
7+
8+
import airbyte_api_client
9+
import click
10+
from octavia_cli.check_context import REQUIRED_PROJECT_DIRECTORIES, requires_init
11+
12+
from .resources import BaseResource
13+
from .resources import factory as resource_factory
14+
15+
16+
@click.command(name="apply", help="Create an Airbyte resources from a YAML definition")
17+
@click.option("--file", "-f", "configurations_files", type=click.Path(), multiple=True)
18+
@click.option("--force", is_flag=True, default=False, help="Does not display the diff and updates without user prompt.")
19+
@click.pass_context
20+
@requires_init
21+
def apply(ctx: click.Context, configurations_files: List[click.Path], force: bool):
22+
if not configurations_files:
23+
configurations_files = find_local_configuration_files()
24+
25+
resources = get_resources_to_apply(configurations_files, ctx.obj["API_CLIENT"], ctx.obj["WORKSPACE_ID"])
26+
for resource in resources:
27+
apply_single_resource(resource, force)
28+
29+
30+
def get_resources_to_apply(
31+
configuration_files: List[str], api_client: airbyte_api_client.ApiClient, workspace_id: str
32+
) -> List[BaseResource]:
33+
"""Create resource objects with factory and sort according to apply priority.
34+
35+
Args:
36+
configuration_files (List[str]): List of YAML configuration files.
37+
api_client (airbyte_api_client.ApiClient): the Airbyte API client.
38+
workspace_id (str): current Airbyte workspace id.
39+
40+
Returns:
41+
List[BaseResource]: Resources sorted according to their apply priority.
42+
"""
43+
all_resources = [resource_factory(api_client, workspace_id, path) for path in configuration_files]
44+
return sorted(all_resources, key=lambda resource: resource.apply_priority)
45+
46+
47+
def apply_single_resource(resource: BaseResource, force: bool) -> None:
48+
"""Runs resource creation if it was not created, update it otherwise.
49+
50+
Args:
51+
resource (BaseResource): The resource to apply.
52+
force (bool): Wetheir force mode is on.
53+
"""
54+
if resource.was_created:
55+
click.echo(
56+
click.style(f"🐙 - {resource.resource_name} exists on your Airbyte instance, let's check if we need to update it!", fg="yellow")
57+
)
58+
messages = update_resource(resource, force)
59+
else:
60+
click.echo(click.style(f"🐙 - {resource.resource_name} does not exists on your Airbyte instance, let's create it!", fg="green"))
61+
messages = create_resource(resource)
62+
click.echo("\n".join(messages))
63+
64+
65+
def should_update_resource(force: bool, diff: str, local_file_changed: bool) -> Tuple[bool, str]:
66+
"""Function to decide if the resource needs an update or not.
67+
68+
Args:
69+
force (bool): Wetheir force mode is on.
70+
diff (str): The computed diff between local and remote for this resource.
71+
local_file_changed (bool): Wetheir the local file describing the resource was modified.
72+
73+
Returns:
74+
Tuple[bool, str]: Boolean to know if resource should be updated and string describing the update reason.
75+
"""
76+
if force:
77+
should_update, update_reason = True, "🚨 - Running update because force mode is on."
78+
elif diff:
79+
should_update, update_reason = True, "✍️ - Running update because a diff was detected between local and remote resource."
80+
elif local_file_changed:
81+
should_update, update_reason = (
82+
True,
83+
"✍️ - Running update because a local file change was detected and a secret field might have been edited.",
84+
)
85+
else:
86+
should_update, update_reason = False, "😴 - Did not update because no change detected."
87+
return should_update, click.style(update_reason, fg="green")
88+
89+
90+
def display_diff_line(diff_line: str) -> None:
91+
"""Prettify a diff line and print it to standard output.
92+
93+
Args:
94+
diff_line (str): The diff line to display.
95+
"""
96+
if "changed from" in diff_line:
97+
color = "yellow"
98+
prefix = "E"
99+
elif "added" in diff_line:
100+
color = "green"
101+
prefix = "+"
102+
elif "removed" in diff_line:
103+
color = "red"
104+
prefix = "-"
105+
else:
106+
prefix = ""
107+
color = None
108+
click.echo(click.style(f"\t{prefix} - {diff_line}", fg=color))
109+
110+
111+
def prompt_for_diff_validation(resource_name: str, diff: str) -> bool:
112+
"""Display the diff to user and prompt them from validation.
113+
114+
Args:
115+
resource_name (str): Name of the resource the diff was computed for.
116+
diff (str): The diff.
117+
118+
Returns:
119+
bool: Wetheir user validated the diff.
120+
"""
121+
if diff:
122+
click.echo(
123+
click.style("👀 - Here's the computed diff (🚨 remind that diff on secret fields are not displayed):", fg="magenta", bold=True)
124+
)
125+
for line in diff.split("\n"):
126+
display_diff_line(line)
127+
return click.confirm(click.style(f"❓ - Do you want to update {resource_name}?", bold=True))
128+
else:
129+
return False
130+
131+
132+
def create_resource(resource: BaseResource) -> List[str]:
133+
"""Run a resource creation.
134+
135+
Args:
136+
resource (BaseResource): The resource to create.
137+
138+
Returns:
139+
List[str]: Post create messages to display to standard output.
140+
"""
141+
created_resource, state = resource.create()
142+
return [
143+
click.style(f"🎉 - Successfully created {created_resource.name} on your Airbyte instance!", fg="green", bold=True),
144+
click.style(f"💾 - New state for {created_resource.name} saved at {state.path}", fg="yellow"),
145+
]
146+
147+
148+
def update_resource(resource: BaseResource, force: bool) -> List[str]:
149+
"""Run a resource update. Check if update is required and prompt for user diff validation if needed.
150+
151+
Args:
152+
resource (BaseResource): Resource to update
153+
force (bool): Wetheir force mode is on.
154+
155+
Returns:
156+
List[str]: Post update messages to display to standard output.
157+
"""
158+
diff = resource.get_diff_with_remote_resource()
159+
should_update, update_reason = should_update_resource(force, diff, resource.local_file_changed)
160+
output_messages = [update_reason]
161+
if not force and diff:
162+
should_update = prompt_for_diff_validation(resource.resource_name, diff)
163+
if should_update:
164+
updated_resource, state = resource.update()
165+
output_messages.append(
166+
click.style(f"🎉 - Successfully updated {updated_resource.name} on your Airbyte instance!", fg="green", bold=True)
167+
)
168+
output_messages.append(click.style(f"💾 - New state for {updated_resource.name} stored at {state.path}.", fg="yellow"))
169+
return output_messages
170+
171+
172+
def find_local_configuration_files() -> List[str]:
173+
"""Discover local configuration files.
174+
175+
Returns:
176+
List[str]: Paths to YAML configuration files.
177+
"""
178+
configuration_files = []
179+
for resource_directory in REQUIRED_PROJECT_DIRECTORIES:
180+
configuration_files += glob(f"./{resource_directory}/**/configuration.yaml")
181+
if not configuration_files:
182+
click.echo(click.style("😒 - No YAML file found to run apply.", fg="red"))
183+
return configuration_files
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
import hashlib
6+
from typing import Any
7+
8+
from deepdiff import DeepDiff
9+
10+
SECRET_MASK = "**********"
11+
12+
13+
def compute_checksum(file_path: str) -> str:
14+
"""Compute SHA256 checksum from a file
15+
16+
Args:
17+
file_path (str): Path for the file for which you want to compute a checksum.
18+
19+
Returns:
20+
str: The computed hash digest
21+
"""
22+
BLOCK_SIZE = 65536
23+
file_hash = hashlib.sha256()
24+
with open(file_path, "rb") as f:
25+
fb = f.read(BLOCK_SIZE)
26+
while len(fb) > 0:
27+
file_hash.update(fb)
28+
fb = f.read(BLOCK_SIZE)
29+
return file_hash.hexdigest()
30+
31+
32+
def exclude_secrets_from_diff(obj: Any, path: str) -> bool:
33+
"""Callback function used with DeepDiff to ignore secret values from the diff.
34+
35+
Args:
36+
obj (Any): Object for which a diff will be computed.
37+
path (str): unused.
38+
39+
Returns:
40+
bool: Wetheir to ignore the object from the diff.
41+
"""
42+
if isinstance(obj, str):
43+
return True if SECRET_MASK in obj else False
44+
else:
45+
return False
46+
47+
48+
def compute_diff(a: Any, b: Any) -> DeepDiff:
49+
"""Wrapper around the DeepDiff computation.
50+
51+
Args:
52+
a (Any): Object to compare with b.
53+
b (Any): Object to compare with a.
54+
55+
Returns:
56+
DeepDiff: the computed diff object.
57+
"""
58+
return DeepDiff(a, b, view="tree", exclude_obj_callback=exclude_secrets_from_diff)

0 commit comments

Comments
 (0)