Initial commit

This commit is contained in:
2022-11-14 21:08:45 +02:00
commit 34f3a878d9
24 changed files with 1978 additions and 0 deletions

221
app/harbor-operator.py Executable file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
import os
import kopf
from base64 import b64encode
from json import dumps
from kubernetes_asyncio.client.exceptions import ApiException
from kubernetes_asyncio import client, config
from sanic import Sanic
from sanic.response import json
from image_mutation import mutate_image
from harbor_wrapper import Harbor
harbor = Harbor(os.environ["HARBOR_URI"])
cached_registries = set()
app = Sanic("admission_control")
@app.post("/")
async def admission_control_handler(request):
patches = []
for index, container in enumerate(request.json["request"]["object"]["spec"]["containers"]):
patches.append({
"op": "replace",
"path": "/spec/containers/%d/image" % index,
"value": mutate_image(container["image"], harbor.hostname, cached_registries),
})
response = {
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": request.json["request"]["uid"],
"allowed": True,
"patchType": "JSONPatch",
"patch": b64encode(dumps(patches).encode("ascii")).decode("ascii")
}
}
return json(response)
@kopf.on.resume("harborcredentials")
@kopf.on.create("harborcredentials")
async def credentialCreation(name, namespace, body, **kwargs):
v1 = client.CoreV1Api()
project_name = body["spec"]["project"]
username = "harbor-operator_%s_%s" % (namespace, name)
try:
dockerconfig, username, password, robot_id = await harbor.create_robot_account(
project_name,
username,
body["spec"]["permissions"])
except Harbor.NoSuchProject:
raise kopf.TemporaryError("PROJECT_MISSING", delay=300)
except Harbor.RobotAccountAlreadyExists:
# We can't read the password to retry, so just let's fail gracefully
raise kopf.TemporaryError("ROBOT_ACCOUNT_ALREADY_EXISTS")
else:
data = {}
data[body["spec"]["key"]] = b64encode(dockerconfig.encode("ascii")).decode("ascii")
kwargs = {
"api_version": "v1",
"data": data,
"kind": "Secret",
"metadata": {
"name": body["metadata"]["name"]
}
}
if body["spec"].get("type"):
kwargs["type"] = body["spec"]["type"]
kopf.adopt(kwargs)
await v1.create_namespaced_secret(body["metadata"]["namespace"],
client.V1Secret(**kwargs))
return {"state": "READY", "id": robot_id, "project": project_name}
@kopf.on.delete("harborcredentials")
async def credential_deletion(name, namespace, body, **kwargs):
try:
project_name = body["status"]["credentialCreation"]["project"]
robot_id = body["status"]["credentialCreation"]["id"]
except KeyError:
pass
else:
await harbor.delete_robot_account(project_name, robot_id)
@kopf.on.resume("clusterharborprojects")
@kopf.on.create("clusterharborprojects")
async def projectCreation(name, namespace, body, **kwargs):
kwargs = {
"project_name": name,
"public": body["spec"]["public"],
"quota": body["spec"]["quota"],
}
if body["spec"]["cache"]:
api_instance = client.CustomObjectsApi()
try:
registry_spec = await api_instance.get_cluster_custom_object("codemowers.io",
"v1alpha1", "clusterharborregistries", name)
except ApiException as e:
if e.status == 404:
raise kopf.TemporaryError("NO_REGISTRY")
try:
registry_id = registry_spec["status"]["registryCreation"]["id"]
except KeyError:
raise kopf.TemporaryError("REGISTRY_NOT_READY")
kwargs["registry_id"] = registry_id
project = await harbor.create_project(**kwargs)
if body["spec"]["cache"]:
cached_registries.add(name)
return {"state": "READY", "id": project["project_id"]}
@kopf.on.delete("clusterharborprojects")
async def project_deletion(name, body, **kwargs):
cached_registries.discard(name)
try:
project_id = body["status"]["projectCreation"]["id"]
except KeyError:
pass
else:
await harbor.delete_project_by_id(project_id)
HARBOR_ROLES = {
"PROJECT_ADMIN": 1,
"DEVELOPER": 2,
"GUEST": 3,
"MAINTAINER": 4,
}
@kopf.on.resume("clusterharborprojectmembers")
@kopf.on.create("clusterharborprojectmembers")
async def memberCreation(name, namespace, body, **kwargs):
api_instance = client.CustomObjectsApi()
try:
project_spec = await api_instance.get_cluster_custom_object("codemowers.io",
"v1alpha1", "clusterharborprojects", body["spec"]["project"])
except ApiException as e:
if e.status == 404:
raise kopf.TemporaryError("NO_PROJECT")
try:
project_id = project_spec["status"]["projectCreation"]["id"]
except KeyError:
raise kopf.TemporaryError("PROJECT_NOT_READY")
try:
membership_id = await harbor.add_project_member(project_id,
body["spec"]["username"], HARBOR_ROLES[body["spec"]["role"]])
except Harbor.UserNotProvisioned:
# User has not logged in yet with OIDC and we don't have mechanism
# to provision OIDC user accounts either
raise kopf.TemporaryError("USER_NOT_PROVISIONED", delay=300)
return {"state": "READY", "id": membership_id, "project_id": project_id}
@kopf.on.delete("clusterharborprojectmembers")
async def member_deletion(name, body, **kwargs):
try:
membership_id = body["status"]["memberCreation"]["id"]
project_id = body["status"]["memberCreation"]["project_id"]
except KeyError:
membership_id = 0
if membership_id:
await harbor.delete_project_member(project_id, membership_id)
@kopf.on.resume("clusterharborregistries")
@kopf.on.create("clusterharborregistries")
async def registryCreation(name, body, **kwargs):
registry_id = await harbor.create_registry_endpoint(name,
body["spec"]["type"], body["spec"]["endpoint"])
return {"state": "READY", "id": registry_id}
@kopf.on.delete("clusterharborregistries")
async def registry_deletion(name, body, **kwargs):
await harbor.delete_registry_endpoint(body["status"]["registryCreation"]["id"])
@kopf.on.startup()
def configure(settings: kopf.OperatorSettings, **_):
settings.scanning.disabled = True
settings.posting.enabled = False
settings.persistence.finalizer = "harbor-operator"
print("Kopf operator starting up")
@app.listener("before_server_start")
async def setup_db(app, loop):
if os.getenv("KUBECONFIG"):
await config.load_kube_config()
else:
config.load_incluster_config()
app.ctx.cached_registries = set()
api_instance = client.CustomObjectsApi()
resp = await api_instance.list_cluster_custom_object("codemowers.io",
"v1alpha1", "clusterharborprojects")
for body in resp["items"]:
if not body["spec"]["cache"]:
continue
try:
project_id = body["status"]["projectCreation"]["id"]
except KeyError:
project_id = 0
if project_id:
cached_registries.add(body["metadata"]["name"])
print("Caching registries:", cached_registries)
app.add_task(kopf.operator(
clusterwide=True))
kwargs = {}
if os.path.exists("/tls"):
kwargs["ssl"] = {"key": "/tls/tls.key", "cert": "/tls/tls.crt"}
app.run(host="0.0.0.0", port=3001, single_process=True,
motd=False, **kwargs)

178
app/harbor_wrapper.py Normal file
View File

@@ -0,0 +1,178 @@
import aiohttp
import re
from base64 import b64encode
from json import dumps
from urllib.parse import urlsplit
class Harbor(object):
class Error(Exception):
pass
class NoSuchProject(Error):
pass
class RobotAccountAlreadyExists(Error):
pass
class UserAlreadyMember(Error):
pass
class UserNotProvisioned(Error):
pass
def __init__(self, base_url):
self.base_url = base_url
self.hostname = urlsplit(base_url).hostname
async def delete_registry_endpoint(self, registry_id):
async with aiohttp.ClientSession() as session:
await session.request(
"DELETE", "%s/api/v2.0/registries/%d" % (self.base_url, registry_id))
async def create_registry_endpoint(self, reg_name, reg_type, reg_url):
body = {
"credential": {
"access_key": "",
"access_secret": "",
"type": "basic"
},
"description": "",
"name": reg_name,
"type": reg_type,
}
if reg_url:
body["url"] = reg_url
body["insecure"] = False
async with aiohttp.ClientSession() as session:
resp = await session.request(
"POST", "%s/api/v2.0/registries" % self.base_url, json=body)
if resp.status not in (201, 409):
raise self.Error("Unexpected status code %d for "
"registry endpoint creation" % resp.status)
async with aiohttp.ClientSession() as session:
resp = await session.request(
"GET", "%s/api/v2.0/registries" % self.base_url)
if resp.status not in (200, 409):
raise self.Error("Unexpected status code %d for "
"registry endpoint lookup" % resp.status)
registries = await resp.json()
for registry in registries:
if registry["name"] == reg_name:
return registry["id"]
raise self.Error("Failed to lookup registry endpoint %s" %
repr(reg_name))
async def get_project(self, project_name):
async with aiohttp.ClientSession() as session:
resp = await session.request(
"GET", "%s/api/v2.0/projects/%s" % (self.base_url, project_name))
if resp.status == 200:
return await resp.json()
elif resp.status == 404:
return None
elif resp.status == 403: # TODO: ??
return None
else:
raise self.Error("Unexpected status code %d for "
"project lookup" % resp.status)
async def delete_project_by_id(self, project_id):
async with aiohttp.ClientSession() as session:
await session.request(
"DELETE", "%s/api/v2.0/projects/%d" % (self.base_url, project_id))
async def delete_project_by_name(self, project_name):
async with aiohttp.ClientSession() as session:
await session.request(
"DELETE", "%s/api/v2.0/projects/%s" % (self.base_url, project_name))
# TODO: Check status code
async def delete_project_member(self, project_id, membership_id):
async with aiohttp.ClientSession() as session:
await session.request(
"DELETE", "%s/api/v2.0/projects/%d/members/%d" % (self.base_url, project_id, membership_id))
# TODO: Check status code
async def delete_robot_account(self, project_name, membership_id):
async with aiohttp.ClientSession() as session:
await session.request(
"DELETE", "%s/api/v2.0/projects/%s/robots/%d" % (self.base_url, project_name, membership_id))
# TODO: Check status code
async def create_project(self, project_name, public, quota, registry_id=None):
async with aiohttp.ClientSession() as session:
resp = await session.request(
"POST", "%s/api/v2.0/projects" % self.base_url, json={
"metadata": {
"public": str(public).lower()
},
"project_name": project_name,
"storage_limit": quota,
"registry_id": registry_id
})
if resp.status not in (201, 409):
raise self.Error("Unexpected status code %d for project "
"creation" % resp.status)
return await self.get_project(project_name)
async def add_project_member(self, project_id, username, role_id):
async with aiohttp.ClientSession() as session:
response = await session.post(
"%s/api/v2.0/projects/%d/members" % (self.base_url, project_id),
json={
"role_id": role_id,
"member_user": {
"username": username
}
}
)
if response.status == 201:
m = re.search("/members/([0-9]+)$", response.headers["Location"])
return int(m.groups()[0])
elif response.status == 409:
return 0
elif response.status == 404:
raise self.UserNotProvisioned(username)
raise self.Error("Got unexpected response from Harbor: %s" % response.status)
async def create_robot_account(self, project_name, account_name, permissions):
async with aiohttp.ClientSession() as session:
response = await session.post(
"%s/api/v2.0/robots" % self.base_url,
json={
"name": account_name,
"duration": -1,
"description": "Robot account created by harbor-operator",
"disable": False,
"level": "project",
"permissions": [{
"namespace": project_name,
"kind": "project",
"access": permissions
}]
}
)
if response.status == 201:
response_json = await response.json()
auth = response_json["name"].encode("ascii"), \
response_json["secret"].encode("ascii")
auths = {}
auths[self.hostname] = {
"auth": b64encode(b"%s:%s" % auth).decode("ascii")
}
dockerconfig = dumps({
"auths": auths
})
m = re.search("/robots/([0-9]+)$", response.headers["Location"])
robot_id = int(m.groups()[0])
return dockerconfig, response_json["name"], response_json["secret"], robot_id
elif response.status == 409:
raise self.RobotAccountAlreadyExists()
elif response.status == 403:
raise self.NoSuchProject(project_name)
raise self.Error("Got unexpected response from Harbor: %s" % response.status)

45
app/image_mutation.py Normal file
View File

@@ -0,0 +1,45 @@
import re
RE_IMAGE = re.compile("^((?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
"(?:(?:\\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?"
"(?::[0-9]+)?/)?[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?"
"(?:(?:/[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?)+)?)"
"(?::([\\w][\\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*"
"(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}))?$")
def parse_image(foo):
m = RE_IMAGE.match(foo)
if not m:
raise ValueError("%s does not match Docker image regex" % repr(foo))
image, tag, digest = m.groups()
try:
org, image = foo.rsplit("/", 1)
except ValueError:
org = "library"
try:
registry, org = org.rsplit("/", 1)
except ValueError:
registry = "docker.io"
if "/" in registry:
raise ValueError("Won't allow caching Docker registry in image name")
return registry, org, image, tag, digest
def mutate_image(foo, hostname, cached_registries):
registry, org, image, tag, digest = parse_image(foo)
j = "%s/%s/%s" % (registry, org, image)
if tag:
j = "%s:%s" % (j, tag)
if digest:
# TODO: Test this
j = "%s@%s" % (j, digest)
if registry in cached_registries:
j = "%s/%s" % (hostname, j)
return j
assert mutate_image("mongo:latest", "harbor.k-space.ee", ("docker.io")) == "harbor.k-space.ee/docker.io/library/mongo:latest"
assert mutate_image("mongo", "harbor.k-space.ee", ("docker.io")) == "harbor.k-space.ee/docker.io/library/mongo"
assert mutate_image("library/mongo", "harbor.k-space.ee", ("docker.io")) == "harbor.k-space.ee/docker.io/library/mongo"
assert mutate_image("docker.io/library/mongo", "harbor.k-space.ee", ("docker.io")) == "harbor.k-space.ee/docker.io/library/mongo"