Skip to content

Commit

Permalink
Update Strigo class from config
Browse files Browse the repository at this point in the history
  • Loading branch information
zigarn committed Feb 19, 2021
1 parent b7db12a commit 4ee4b9d
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 28 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ positional arguments:
COMMAND sub-command help
create Create config for new Strigo class. The class parameters are asked interactively.
retrieve Retrieve config from existing Strigo class
update Update Strigo class from config
optional arguments:
-h, --help show this help message and exit
Expand Down Expand Up @@ -82,6 +83,22 @@ This command can be used to create a [configuration](#configuration) and the cor
- The configuration will be stored inside a `strigo.json` file at the root of your training (or one referenced by `--config`)
- The class will be created on Strigo

### Update Strigo class from configuration

```shell-session
$ ztraining2strigo update --help
usage: ztraining2strigo update [-h] [--dry-run]
optional arguments:
-h, --help show this help message and exit
--dry-run, -n Do not perform update
```

This command can be used to update a Strigo class from local [configuration](#configuration).

- Update is idempotent: if Strigo class is already as described by configuration, nothing will be done
- It is possible to check if an updated should be performed by using the `--dry-run` option

## Configuration

Configuration is stored in JSON format inside a `strigo.json` file at the root of your training (or one referenced by `--config`).
Expand Down
14 changes: 13 additions & 1 deletion src/strigo/configs/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import List
from typing import Any, Dict, List

from ..models.classes import Class
from ..models.presentations import Presentation
Expand All @@ -24,6 +24,18 @@ def write(self, config_path: Path) -> None:
with config_path.open('w') as f:
json.dump(asdict(self), f, indent=2, sort_keys=True)

@staticmethod
def load(config_path: Path) -> ClassConfig:
with config_path.open() as f:
raw_config = json.load(f)
return ClassConfig.from_dict(raw_config)

@staticmethod
def from_dict(d: Dict[str, Any]) -> ClassConfig:
d['presentations'] = [PresentationConfig.from_dict(e) for e in d['presentations']]
d['resources'] = [ResourceConfig.from_dict(e) for e in d['resources']]
return ClassConfig(**d)

@staticmethod
def from_strigo(cls: Class, presentations: List[Presentation]) -> ClassConfig:
return ClassConfig(
Expand Down
16 changes: 16 additions & 0 deletions src/strigo/configs/presentations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from __future__ import annotations

from dataclasses import dataclass
from hashlib import md5
from pathlib import Path
from typing import Any, Dict

from ..models.presentations import Presentation

Expand All @@ -12,10 +14,24 @@ class PresentationConfig:
file: str
notes_source: str = 'Slides/slides.json'

@staticmethod
def from_dict(d: Dict[str, Any]) -> PresentationConfig:
return PresentationConfig(**d)

@staticmethod
def from_strigo(presentation: Presentation) -> PresentationConfig:
return PresentationConfig(search_file(presentation.filename))

def file_size(self) -> int:
return Path(self.file).stat().st_size

def file_md5_sum(self) -> str:
hasher = md5()
with Path(self.file).open('rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
hasher.update(chunk)
return hasher.hexdigest()


def search_file(filename: str) -> str:
paths = list(Path('.').glob(f"**/{filename}"))
Expand Down
13 changes: 12 additions & 1 deletion src/strigo/configs/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import List, Union
from typing import Any, Dict, List, Union

from ..models.resources import Resource, WebviewLink
from . import get_scripts_folder
Expand Down Expand Up @@ -83,6 +83,10 @@ class ResourceImageConfig:
image_user: str
ec2_region: str = None

@staticmethod
def from_dict(d: Dict[str, Any]) -> ResourceImageConfig:
return ResourceImageConfig(**d)


@dataclass
class ResourceConfig:
Expand All @@ -93,6 +97,13 @@ class ResourceConfig:
post_launch_scripts: List[str] = field(default_factory=list)
webview_links: List[WebviewLink] = field(default_factory=list)

@staticmethod
def from_dict(d: Dict[str, Any]) -> ResourceConfig:
if not isinstance(d['image'], str):
d['image'] = ResourceImageConfig.from_dict(d['image'])
d['webview_links'] = [WebviewLink.from_dict(e) for e in d['webview_links']]
return ResourceConfig(**d)

@staticmethod
def from_strigo(resource: Resource) -> ResourceConfig:
if not resource.is_custom_image:
Expand Down
179 changes: 153 additions & 26 deletions src/ztraining2strigo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import os
import sys
from getpass import getpass
from itertools import zip_longest
from pathlib import Path
from typing import Callable, List
from typing import Any, Callable, List

from strigo.api import UNDEFINED
from strigo.api import classes as classes_api
from strigo.api import presentations as presentations_api
from strigo.api import resources as resources_api
Expand All @@ -15,7 +17,8 @@
from strigo.configs.classes import ClassConfig
from strigo.configs.presentations import PresentationConfig
from strigo.configs.resources import AWS_REGIONS, STRIGO_DEFAULT_INSTANCE_TYPES, STRIGO_DEFAULT_REGION, STRIGO_IMAGES, ResourceConfig, ResourceImageConfig
from strigo.models.resources import WebviewLink
from strigo.models.classes import Class
from strigo.models.resources import Resource, WebviewLink

from .notes_parser import parse_notes

Expand Down Expand Up @@ -54,6 +57,140 @@ def _confirm(prompt: str) -> bool:
print('Please answer by y[es] or n[o]', file=sys.stderr)


def _are_nonish_equals(a: Any, b: Any) -> bool:
return (
(a is None or a is UNDEFINED or a == '' or a == [] or a == {})
and
(b is None or b is UNDEFINED or b == '' or b == [] or b == {})
)


def _to_strigo(client: Client, config: ClassConfig, existing_class: Class = None, dry_run: bool = False) -> None:
messages_prefix = ''
if dry_run:
messages_prefix = '(dry-run) '

if not existing_class:
existing_class = classes_api.get(client, config.id)

needs_update = False
if config.name != existing_class.name:
print(f"Will update class name from {existing_class.name} to {config.name}")
needs_update = True
if config.description != existing_class.description:
print(f"Will update class description from {existing_class.description} to {config.description}")
needs_update = True
if needs_update and not dry_run:
print(f"{messages_prefix}Updating class {existing_class.id}")
classes_api.update(client, existing_class.id, config.name, config.description)

existing_presentations = presentations_api.list(client, existing_class.id)
existing_presentations_per_filename = {p.filename: p for p in existing_presentations}
presentations_per_filename = {Path(p.file).name: p for p in config.presentations}
for presentation in (p for p in existing_presentations if p.filename not in presentations_per_filename):
print(f"{messages_prefix}Deleting existing presentation with id {presentation.id} of file {presentation.filename}")
if not dry_run:
presentations_api.delete(client, existing_class.id, presentation.id)
for presentation in (p for f, p in presentations_per_filename.items() if f not in existing_presentations_per_filename):
print(f"{messages_prefix}Creating presentation {presentation.file}")
if not dry_run:
created_presentation = presentations_api.create(client, existing_class.id, Path(presentation.file))
if created_presentation:
presentations_api.create_notes(client, existing_class.id, created_presentation.id, parse_notes(Path(presentation.notes_source)))
for presentation, existing_presentation in ((p, existing_presentations_per_filename[f]) for f, p in presentations_per_filename.items() if f in existing_presentations_per_filename):
notes = parse_notes(Path(presentation.notes_source))
needs_update = presentation.file_size() != existing_presentation.size_bytes
if not needs_update: # Don't verify checksum if update is already needed
needs_update = presentation.file_md5_sum() != existing_presentation.md5
if needs_update:
print(f"{messages_prefix}Updating presentation {presentation.file}")
if not dry_run:
updated_presentation = presentations_api.update(client, existing_class.id, existing_presentation.id, Path(presentation.file))
if updated_presentation:
presentations_api.create_notes(client, existing_class.id, updated_presentation.id, notes)
else:
existing_notes = presentations_api.get_notes(client, existing_class.id, existing_presentation.id)
if notes != existing_notes:
print(f"{messages_prefix}Updating presentation notes for {presentation.file}")
if not dry_run:
presentations_api.create_notes(client, existing_class.id, existing_presentation.id, notes)

existing_resources = resources_api.list(client, existing_class.id)
for index, (resource, existing_resource) in enumerate(zip_longest(config.resources, existing_resources)):
resource: ResourceConfig
existing_resource: Resource
if resource is None:
print(f"{messages_prefix}Deleting machine {index} named {existing_resource.name}")
if not dry_run:
resources_api.delete(client, existing_class.id, existing_resource.id)
else:
image = resource.image
if isinstance(image, str):
image = ResourceImageConfig(
STRIGO_IMAGES[image]['amis'][STRIGO_DEFAULT_REGION],
STRIGO_IMAGES[image]['user'],
STRIGO_DEFAULT_REGION
)

init_script = ''
for script in resource.init_scripts:
with Path(script).open() as f:
init_script += f.read() + '\n'
if init_script == '':
init_script = UNDEFINED
post_launch_script = ''

for script in resource.post_launch_scripts:
with Path(script).open() as f:
post_launch_script += f.read() + '\n'
if post_launch_script == '':
post_launch_script = UNDEFINED

if existing_resource is None:
print(f"{messages_prefix}Creating machine {index} named {resource.name}")
if not dry_run:
resources_api.create(
client, existing_class.id, resource.name, image.image_id, image.image_user,
resource.webview_links, post_launch_script, init_script,
image.ec2_region, resource.instance_type
)
else:
needs_update = False
if resource.name != existing_resource.name:
print(f"Will update machine {index} name from {existing_resource.name} to {resource.name}")
needs_update = True
if resource.instance_type != existing_resource.instance_type:
print(f"Will update machine {index} type from {existing_resource.instance_type} to {resource.instance_type}")
needs_update = True
if image.image_id != existing_resource.image_id:
print(f"Will update machine {index} image from {existing_resource.image_id} to {image.image_id}")
needs_update = True
if image.image_user != existing_resource.image_user:
print(f"Will update machine {index} image user from {existing_resource.image_user} to {image.image_user}")
needs_update = True
if image.ec2_region != existing_resource.ec2_region:
print(f"Will update machine {index} image regions from {existing_resource.ec2_region} to {image.ec2_region}")
needs_update = True
if init_script != existing_resource.userdata and not _are_nonish_equals(init_script, existing_resource.userdata):
print(f"Will update machine {index} init script")
needs_update = True
if post_launch_script != existing_resource.post_launch_script and not _are_nonish_equals(post_launch_script, existing_resource.post_launch_script):
print(f"Will update machine {index} post launch script")
needs_update = True
if resource.webview_links != existing_resource.webview_links:
print(f"Will update machine {index} webview links")
needs_update = True
if needs_update:
print(f"{messages_prefix}Updating machine {index} named {resource.name}")
if not dry_run:
resources_api.update(
client, existing_class.id, existing_resource.id,
resource.name, image.image_id, image.image_user,
resource.webview_links, post_launch_script, init_script,
image.ec2_region, resource.instance_type
)


def create(client: Client, args: argparse.Namespace) -> None:
config_file = bootstrap_config_file(args.config)

Expand Down Expand Up @@ -140,30 +277,7 @@ def is_webview_link_name_valid(name: str) -> bool:
strigo_config.write(config_file)
print(f"Config stored in '{config_file.absolute()}'")

print("Uploading Strigo class presentation...")
presentation = presentations_api.create(client, cls.id, presentation)
if presentation:
presentations_api.create_notes(client, cls.id, presentation.id, parse_notes(Path(presentation_config.notes_source)))

print("Adding Strigo class resources...")
for resource in resources:
image = resource.image
if isinstance(image, str):
image = ResourceImageConfig(
STRIGO_IMAGES[image]['amis'][STRIGO_DEFAULT_REGION],
STRIGO_IMAGES[image]['user'],
STRIGO_DEFAULT_REGION
)
userdata = ''
for script in resource.init_scripts:
with Path(script).open() as f:
userdata += f.read() + '\n'
post_launch_script = ''
for script in resource.post_launch_scripts:
with Path(script).open() as f:
post_launch_script += f.read() + '\n'
resources_api.create(client, cls.id, resource.name, image.image_id, image.image_user, resource.webview_links, post_launch_script, userdata, image.ec2_region, resource.instance_type)

_to_strigo(client, strigo_config, existing_class=cls)
print("Done!")


Expand All @@ -177,6 +291,15 @@ def retrieve(client: Client, args: argparse.Namespace) -> None:
print(f"Config from Strigo stored in '{config_file.absolute()}'")


def update(client: Client, args: argparse.Namespace) -> None:
if not args.config.exists():
print(f"ERROR: Config file {args.config} does not exists.", file=sys.stderr)
exit(1)

strigo_config = ClassConfig.load(args.config)
_to_strigo(client, strigo_config, dry_run=args.dry_run)


def main() -> None:
parser = argparse.ArgumentParser('ztraining2strigo')
parser.add_argument('--config', default='strigo.json', type=Path)
Expand All @@ -189,6 +312,10 @@ def main() -> None:
parser_retrieve.add_argument('class_id', metavar='CLASS_ID', type=str, help='Existing Strigo class ID')
parser_retrieve.set_defaults(func=retrieve)

parser_update = subparsers.add_parser('update', help='Update Strigo class from config')
parser_update.add_argument('--dry-run', '-n', action='store_true', help='Do not perform update')
parser_update.set_defaults(func=update)

args = parser.parse_args()

strigo_org_id = os.environ.get('STRIGO_ORG_ID', None)
Expand Down

0 comments on commit 4ee4b9d

Please sign in to comment.