Skip to content

Commit

Permalink
Support skip-base-builds and target builds in CLI
Browse files Browse the repository at this point in the history
 - improved pydantic config validation with forbidden extras
  • Loading branch information
spillai committed Oct 23, 2023
1 parent adc0a44 commit 43f652e
Show file tree
Hide file tree
Showing 8 changed files with 79 additions and 26 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

📦 **`agi-pack`** allows you to define your Docker images using a simple YAML format, and then generate them on-the-fly using [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates with [Pydantic](https://docs.pydantic.dev/latest/)-based validation. It's a simple tool that aims to simplify the process of building Docker images for ML.

🚨 **Disclaimer:** More than **75%** of this initial implementation was generated by GPT-4 and [Github Co-Pilot](https://github.com/features/copilot). See [attribution](#inspiration-and-attribution-🌟) section below for more details.
🚨 **Disclaimer:** More than **75%** of the original implementation was generated by GPT-4 and [Github Co-Pilot](https://github.com/features/copilot). I've spent enough time building on it that it's probably **<30%** auto-generated code now. See [attribution](#inspiration-and-attribution-🌟) section below for more details.

## Goals 🎯

Expand Down Expand Up @@ -151,7 +151,7 @@ Here's the corresponding [`Dockerfile`](./examples/generated/Dockerfile-multista

📦 **agi-pack** is simply a weekend project I hacked together, that started with a conversation with [ChatGPT / GPT-4](#chatgpt-prompt).

🚨 **Disclaimer:** More than **75%** of this initial implementation was generated by GPT-4 and [Github Co-Pilot](https://github.com/features/copilot).
🚨 **Disclaimer:** More than **75%** of this initial implementation was generated by GPT-4 and [Github Co-Pilot](https://github.com/features/copilot). I've spent enough time building on it that it's probably **<30%** auto-generated code now.

### ChatGPT Prompt
---
Expand Down
8 changes: 7 additions & 1 deletion agipack/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class AGIPackRenderOptions:
env: str = field(default=AGIPACK_ENV)
"""AGIPack environment to use for the build."""

skip_base_builds: bool = field(default=False)
"""Skip building the base images."""

def is_prod(self) -> bool:
"""Check if the build is for production."""
return self.env == "prod"
Expand Down Expand Up @@ -96,7 +99,10 @@ def _render_one(self, target: str, image_config: ImageConfig, options: AGIPackRe

# Render the Dockerfile template
image_dict["target"] = target
image_dict["is_base_image"] = self.config.is_root(target)
if options.skip_base_builds:
image_dict["is_base_image"] = False
else:
image_dict["is_base_image"] = self.config.is_root(target)
image_dict["is_prod"] = options.is_prod()
image_dict["agipack_version"] = __version__
content = template.render(image_dict)
Expand Down
30 changes: 27 additions & 3 deletions agipack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,13 @@ def generate(
None, "--base", "-b", help="Base image to use for the root/base target.", show_default=False
),
tag: str = typer.Option("{name}:{target}", "--tag", "-t", help="Image tag f-string.", show_default=True),
_target: str = typer.Option(None, "--target", help="Build specific target.", show_default=False),
prod: bool = typer.Option(False, "--prod", help="Generate a production Dockerfile.", show_default=False),
lint: bool = typer.Option(False, "--lint", help="Lint the generated Dockerfile.", show_default=False),
build: bool = typer.Option(False, "--build", help="Build the Docker image after generating the Dockerfile."),
skip_base_builds: bool = typer.Option(
False, "--skip-base", help="Skip building the base image.", show_default=False
),
):
"""Generate the Dockerfile with optional overrides.
Expand All @@ -82,12 +86,17 @@ def generate(

# Render the Dockerfiles with the new filename and configuration
builder = AGIPack(config)
dockerfiles = builder.render(filename=filename, env="prod" if prod else "dev")
dockerfiles = builder.render(filename=filename, env="prod" if prod else "dev", skip_base_builds=skip_base_builds)
for target, filename in dockerfiles.items():
# Skip if the target is not the one we want to build
if _target is not None and target != _target:
continue
image_config = config.images[target]

# Build the Dockerfile using the generated filename and target
tag_name = f"{image_config.name}:{target}" if tag is None else tag.format(name=image_config.name, target=target)
tag_name = (
f"{image_config.name}:{target}" if tag is None else tag.format(name=image_config.name, target=target)
)
cmd = f"docker build -f {filename} --target {target} -t {tag_name} ."
tree = Tree(f"📦 [bold white]{target}[/bold white]")
tree.add(
Expand Down Expand Up @@ -121,8 +130,12 @@ def build(
None, "--base", "-b", help="Base image to use for the root/base target.", show_default=False
),
tag: str = typer.Option("{name}:{target}", "--tag", "-t", help="Image tag f-string.", show_default=True),
target: str = typer.Option(None, "--target", help="Build specific target.", show_default=False),
prod: bool = typer.Option(False, "--prod", help="Generate a production Dockerfile.", show_default=False),
lint: bool = typer.Option(False, "--lint", help="Lint the generated Dockerfile.", show_default=False),
skip_base_builds: bool = typer.Option(
False, "--skip-base", help="Skip building the base image.", show_default=False
),
):
"""Generate the Dockerfile with optional overrides.
Expand All @@ -135,7 +148,18 @@ def build(
agi-pack build -c agibuild.yaml -t "my-image-name:my-target"
agi-pack build -c agibuild.yaml --prod --lint
"""
generate(config_filename, filename, python, base_image, tag, prod, lint, build=True)
generate(
config_filename,
filename,
python,
base_image,
tag,
target,
prod,
lint,
build=True,
skip_base_builds=skip_base_builds,
)


if __name__ == "__main__":
Expand Down
35 changes: 28 additions & 7 deletions agipack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Dict, List, Optional, Tuple, Union

import yaml
from pydantic import validator
from pydantic import Extra, validator
from pydantic.dataclasses import dataclass

logger = logging.getLogger(__name__)
Expand All @@ -28,7 +28,13 @@ def is_leaf(self) -> bool:
return not len(self.children)


@dataclass
class _ForbidExtrasConfig:
"""Pydantic config to forbid extra fields."""

extra = Extra.forbid


@dataclass(config=_ForbidExtrasConfig)
class ImageConfig:
"""AGIPack configuration for a docker target specified in `agibuild.yaml`
Expand All @@ -50,16 +56,19 @@ class ImageConfig:
"""

image: str = field(default="agi:latest")
image: str = field(default=None)
"""Name of the image repository.
Defaults to the <target> name if not provided.
Defaults to <name>:<target> if not specified.
"""

name: str = field(default="agi")
name: str = field(default="agipack")
"""Pretty-name of the project in the image and
reflected in the `AGIPACK_ENV` environment variable in the image.
"""

target: str = field(default=None)
"""Name of the target."""

base: str = field(default="debian:buster-slim")
"""Base docker image / target to use (FROM clause in the Dockerfile)."""

Expand Down Expand Up @@ -96,6 +105,12 @@ class ImageConfig:
command: Optional[Union[str, List[str]]] = field(default_factory=list)
"""Command to run in the image."""

def __post_init__(self):
if self.target is None:
self.target = "latest"
if self.image is None:
self.image = f"{self.name}:{self.target}"

def additional_kwargs(self):
"""Additional kwargs to pass to the Jinja2 Dockerfile template."""
python_alias = f"py{''.join(self.python.split('.')[:2])}"
Expand Down Expand Up @@ -211,9 +226,15 @@ def load_yaml(cls, filename: Union[str, Path]) -> "AGIPackConfig":
Returns:
AGIPackConfig: AGIPack configuration.
"""
path = Path(filename)
logger.debug(f"Loading AGIPack configuration from {path}")
if not path.exists():
raise ValueError(f"YAML file {path.name} does not exist")
if not (path.name.endswith(".yaml") or path.name.endswith(".yml")):
raise ValueError(f"YAML file {path.name} must have a .yaml or .yml extension")

# Load the YAML file
logger.debug(f"Loading AGIPack configuration from {filename}")
with open(filename, "r") as f:
with path.open("r") as f:
data = yaml.safe_load(f)
logger.debug(f"AGIPack configuration: {data}")

Expand Down
3 changes: 1 addition & 2 deletions agipack/templates/Dockerfile.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# >>>>>>>>>>>>>>>>>>>>>>>>>>>
# Auto-generated by agi-pack (version={{ agipack_version }}).
{%- else %}

# >>>>>>>>>>>>>>>>>>>>>>>>>>>
{%- endif %}
FROM {{ base }} AS {{ target }}
Expand Down Expand Up @@ -179,7 +178,7 @@ RUN apt-get -y autoclean \

# Setup environment variables
{% for key, value in env.items() -%}
ENV {{ key }}={{ value }}
ENV {{ key }}={{ value }}
{% endfor %}

{%- endif %}
Expand Down
9 changes: 5 additions & 4 deletions examples/generated/Dockerfile-base-cpu
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# >>>>>>>>>>>>>>>>>>>>>>>>>>>
# Auto-generated by agi-pack (version=0.1.15).
# Auto-generated by agi-pack (version=0.1.16).
FROM debian:buster-slim AS base-cpu

# Setup environment variables
ENV AGIPACK_PROJECT agi
ENV AGIPACK_PYENV agi-py38
ENV AGIPACK_PROJECT agipack
ENV AGIPACK_PYENV agipack-py38
ENV AGIPACK_PATH /opt/agi-pack

ENV DEBIAN_FRONTEND="noninteractive"
ENV PYTHON_VERSION 3.8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
Expand Down Expand Up @@ -94,4 +95,4 @@ RUN apt-get -y autoclean \
&& echo "pip cleanup complete"

# Setup environment variables
ENV MY_ENV_VAR=value
ENV MY_ENV_VAR=value
9 changes: 5 additions & 4 deletions examples/generated/Dockerfile-base-cu118
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# >>>>>>>>>>>>>>>>>>>>>>>>>>>
# Auto-generated by agi-pack (version=0.1.15).
# Auto-generated by agi-pack (version=0.1.16).
FROM nvidia/cuda:11.8.0-base-ubuntu22.04 AS base-gpu

# Setup environment variables
ENV AGIPACK_PROJECT agi
ENV AGIPACK_PYENV agi-py38
ENV AGIPACK_PROJECT agipack
ENV AGIPACK_PYENV agipack-py38
ENV AGIPACK_PATH /opt/agi-pack

ENV DEBIAN_FRONTEND="noninteractive"
ENV PYTHON_VERSION 3.8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
Expand Down Expand Up @@ -104,4 +105,4 @@ RUN apt-get -y autoclean \
&& echo "pip cleanup complete"

# Setup environment variables
ENV MY_ENV_VAR=value
ENV MY_ENV_VAR=value
7 changes: 4 additions & 3 deletions examples/generated/Dockerfile-builder
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# >>>>>>>>>>>>>>>>>>>>>>>>>>>
# Auto-generated by agi-pack (version=0.1.15).
# Auto-generated by agi-pack (version=0.1.16).
FROM debian:buster-slim AS agipack-builder

# Setup environment variables
ENV AGIPACK_PROJECT agi
ENV AGIPACK_PYENV agi-py38
ENV AGIPACK_PROJECT agipack
ENV AGIPACK_PYENV agipack-py38
ENV AGIPACK_PATH /opt/agi-pack

ENV DEBIAN_FRONTEND="noninteractive"
ENV PYTHON_VERSION 3.8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
Expand Down

0 comments on commit 43f652e

Please sign in to comment.