Skip to content

Commit

Permalink
Add module to clean the Docker images or a folder in gh-pages branch
Browse files Browse the repository at this point in the history
  • Loading branch information
sbrunner committed Dec 6, 2024
1 parent 0c764d1 commit a3c3e58
Show file tree
Hide file tree
Showing 10 changed files with 837 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .github/spell-ignore-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dpkg
pipenv
datasource
lifecycle
feature_branch
pull_request
8 changes: 7 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,12 @@ repos:
- --pre-commit
- github_app_geo_project/module/versions/schema.json
- VERSIONS-CONFIG.md

- id: jsonschema2md
files: ^github_app_geo_project/module/clean/schema\.json$
args:
- --pre-commit
- github_app_geo_project/module/clean/schema.json
- CLEAN-CONFIG.md
- repo: https://github.com/sbrunner/jsonschema-validator
rev: 0.1.0
hooks:
Expand All @@ -170,6 +175,7 @@ repos:
github_app_geo_project/module/audit/schema\.json
|github_app_geo_project/module/delete_old_workflow_runs/schema\.json
|github_app_geo_project/module/versions/schema\.json
|github_app_geo_project/module/clean/schema\.json
)$
args:
- --fields=description,title
Expand Down
1 change: 1 addition & 0 deletions .prospector.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ignore-paths:
- github_app_geo_project/application_configuration.py
- github_app_geo_project/module/standard/changelog_configuration.py
- github_app_geo_project/module/audit/configuration.py
- github_app_geo_project/module/clean/configuration.py

mypy:
options:
Expand Down
11 changes: 11 additions & 0 deletions CLEAN-CONFIG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Clean modules configuration

## Properties

- **`clean`** _(object)_: Cannot contain additional properties.
- **`docker`** _(boolean)_: Clean the docker images made from feature branches and pull requests. Default: `true`.
- **`git`** _(array)_
- **Items** _(object)_: Clean a folder from a branch. Cannot contain additional properties.
- **`on-type`** _(string)_: feature_branch, pull_request or all. Must be one of: `["feature_branch", "pull_request", "all"]`. Default: `"all"`.
- **`branch`** _(string)_: The branch on witch one the folder will be cleaned. Default: `"gh-pages"`.
- **`folder`** _(string)_: The folder to be cleaned, can contains {name}, that will be replaced with the branch name or pull request number. Default: `"{name}"`.
224 changes: 224 additions & 0 deletions github_app_geo_project/module/clean/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Module to display the status of the workflows in the transversal dashboard."""

import json
import logging
import os.path
import subprocess # nosec
import tempfile
from typing import Any, cast

import github
import requests
import tag_publish.configuration
import yaml
from pydantic import BaseModel

from github_app_geo_project import module
from github_app_geo_project.configuration import GithubProject
from github_app_geo_project.module import utils as module_utils

from . import configuration

_LOGGER = logging.getLogger(__name__)


class _ActionData(BaseModel):
type: str
name: str


class Clean(module.Module[configuration.CleanConfiguration, _ActionData, None]):
"""Module to display the status of the workflows in the transversal dashboard."""

def title(self) -> str:
"""Get the title of the module."""
return "Clean feature artifacts"

def description(self) -> str:
"""Get the description of the module."""
return "Clean feature branches or pull requests artifacts"

def documentation_url(self) -> str:
"""Get the URL to the documentation page of the module."""
return "https://github.com/camptocamp/github-app-geo-project/wiki/Module-%E2%80%90-Clean"

def get_github_application_permissions(self) -> module.GitHubApplicationPermissions:
"""Get the GitHub application permissions needed by the module."""
return module.GitHubApplicationPermissions(
{
"contents": "write",
},
{"pull_request", "delete"},
)

def get_json_schema(self) -> dict[str, Any]:
"""Get the JSON schema for the module."""
with open(os.path.join(os.path.dirname(__file__), "schema.json"), encoding="utf-8") as schema_file:
return json.loads(schema_file.read()).get("properties", {}).get("audit") # type: ignore[no-any-return]

def get_actions(self, context: module.GetActionContext) -> list[module.Action[_ActionData]]:
"""Get the action related to the module and the event."""
if context.event_data.get("action") == "delete":
return [
module.Action(_ActionData(type="pull_request", name="123"), priority=module.PRIORITY_CRON)
]
return []

async def process(
self, context: module.ProcessContext[configuration.CleanConfiguration, _ActionData, None]
) -> module.ProcessOutput[_ActionData, None]:
"""Process the action."""
if context.module_config.get("docker", True):
await self._clean_docker(context)
for git in context.module_config.get("git", []):
await self._clean_git(context, git)

return module.ProcessOutput()

async def _clean_docker(
self, context: module.ProcessContext[configuration.CleanConfiguration, _ActionData, None]
) -> None:
"""Clean the Docker images on Docker Hub for the branch we delete."""
# get the .github/publish.yaml

try:
publish_configuration_content = context.github_project.repo.get_contents(".github/publish.yaml")
assert not isinstance(publish_configuration_content, list)
publish_config = cast(
tag_publish.configuration.Configuration,
yaml.load(publish_configuration_content.decoded_content, Loader=yaml.SafeLoader),
)
except github.UnknownObjectException as exception:
if exception.status != 404:
raise
return

name = context.module_event_data.name
if context.module_event_data.type == "pull_request":
transformers = publish_config.get(
"transformers",
cast(tag_publish.configuration.Transformers, tag_publish.configuration.TRANSFORMERS_DEFAULT),
)
pull_match = tag_publish.match(
name.split("/", 2)[2],
tag_publish.compile_re(
transformers.get(
"pull_request_to_version", cast(tag_publish.configuration.Transform, [{}])
)
),
)
name = tag_publish.get_value(*pull_match)

for repo in (
publish_config.get("docker", {})
.get(
"repository",
cast(
dict[str, tag_publish.configuration.DockerRepository],
tag_publish.configuration.DOCKER_REPOSITORY_DEFAULT,
),
)
.values()
):
if context.module_event_data.type not in repo.get(
"versions_type",
tag_publish.configuration.DOCKER_REPOSITORY_VERSIONS_DEFAULT, # TODO
):
continue
host = repo.get("host", "docker.io")
if host not in ["docker.io", "ghcr.io"]:
_LOGGER.warning("Unsupported host %s", host)
continue
for image in publish_config.get("docker", {}).get("images", []):
for tag in image.get("tags", []):
tag = tag.format(version=name)
_LOGGER.info("Cleaning %s/%s:%s", host, image["name"], tag)

if host == "docker.io":
self._clean_docker_hub_image(image["name"], tag)
else:
self._clean_ghcr_image(image["name"], tag, context.github_project)

def _clean_docker_hub_image(self, image: str, tag: str) -> None:
username = os.environ["DOCKERHUB_USERNAME"]
password = os.environ["DOCKERHUB_PASSWORD"]
token = requests.post(
"https://hub.docker.com/v2/users/login/",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"username": username,
"password": password,
}
),
timeout=int(os.environ.get("GHCI_REQUESTS_TIMEOUT", "30")),
).json()["token"]

response = requests.head(
f"https://hub.docker.com/v2/repositories/{image}/tags/{tag}/",
headers={"Authorization": "JWT " + token},
timeout=int(os.environ.get("C2CCIUTILS_TIMEOUT", "30")),
)
if response.status_code == 404:
return
if not response.ok:
_LOGGER.error("Error checking image: docker.io/%s:%s", image, tag)

response = requests.delete(
f"https://hub.docker.com/v2/repositories/{image}/tags/{tag}/",
headers={"Authorization": "JWT " + token},
timeout=int(os.environ.get("C2CCIUTILS_TIMEOUT", "30")),
)
if not response.ok:
_LOGGER.error("Error on deleting image: docker.io/%s:%s", image, tag)

def _clean_ghcr_image(self, image: str, tag: str, github_project: GithubProject) -> None:
image_split = image.split("/", 1)
response = requests.delete(
f"https://api.github.com/orgs/{image_split[0]}/packages/container/{image_split[1]}/versions/{tag}",
headers={
"Authorization": "Bearer " + github_project.token,
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if not response.ok:
_LOGGER.error("Error on deleting image: ghcr.io/%s:%s", image, tag)

async def _clean_git(
self,
context: module.ProcessContext[configuration.CleanConfiguration, _ActionData, None],
git: configuration.Git,
) -> None:
"""Clean the Git repository for the branch we delete."""
if git.get("on-type", configuration.ON_TYPE_DEFAULT) not in (context.module_event_data.type, "all"):
return

branch = git.get("branch", configuration.BRANCH_DEFAULT)
folder = git.get("folder", configuration.FOLDER_DEFAULT).format(name=context.module_event_data.name)

async with module_utils.WORKING_DIRECTORY_LOCK:
# Checkout the right branch on a temporary directory
with tempfile.TemporaryDirectory() as tmpdirname:
os.chdir(tmpdirname)
_LOGGER.debug("Clone the repository in the temporary directory: %s", tmpdirname)
success = module_utils.git_clone(context.github_project, branch)
if not success:
_LOGGER.error(
"Error on cloning the repository %s/%s",
context.github_project.owner,
context.github_project.repository,
)

os.chdir(context.github_project.repository)
subprocess.run(["git", "rm", folder], check=True)
subprocess.run(
[
"git",
"commit",
"-m",
f"Delete {folder} to clean {context.module_event_data.type} {context.module_event_data.name}",
],
check=True,
)
subprocess.run(["git", "push", "origin", branch], check=True)
85 changes: 85 additions & 0 deletions github_app_geo_project/module/clean/configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Automatically generated file from a JSON schema."""

from typing import Literal, TypedDict, Union

BRANCH_DEFAULT = "gh-pages"
""" Default value of the field path 'git branch' """


class CleanConfiguration(TypedDict, total=False):
"""Clean configuration."""

docker: bool
"""
docker.
Clean the docker images made from feature branches and pull requests
default: True
"""

git: list["Git"]


class CleanModulesConfiguration(TypedDict, total=False):
"""Clean modules configuration."""

clean: "CleanConfiguration"
""" Clean configuration. """


DOCKER_DEFAULT = True
""" Default value of the field path 'Clean configuration docker' """


FOLDER_DEFAULT = "{name}"
""" Default value of the field path 'git folder' """


# | git.
# |
# | Clean a folder from a branch
Git = TypedDict(
"Git",
{
# | on-type.
# |
# | feature_branch, pull_request or all
# |
# | default: all
"on-type": "OnType",
# | branch.
# |
# | The branch on witch one the folder will be cleaned
# |
# | default: gh-pages
"branch": str,
# | folder.
# |
# | The folder to be cleaned, can contains {name}, that will be replaced with the branch name or pull request number
# |
# | default: {name}
"folder": str,
},
total=False,
)


ON_TYPE_DEFAULT = "all"
""" Default value of the field path 'git on-type' """


OnType = Union[Literal["feature_branch"], Literal["pull_request"], Literal["all"]]
"""
on-type.
feature_branch, pull_request or all
default: all
"""
ONTYPE_FEATURE_BRANCH: Literal["feature_branch"] = "feature_branch"
"""The values for the 'on-type' enum"""
ONTYPE_PULL_REQUEST: Literal["pull_request"] = "pull_request"
"""The values for the 'on-type' enum"""
ONTYPE_ALL: Literal["all"] = "all"
"""The values for the 'on-type' enum"""
Loading

0 comments on commit a3c3e58

Please sign in to comment.