Initial commit
This commit is contained in:
221
app/harbor-operator.py
Executable file
221
app/harbor-operator.py
Executable 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
178
app/harbor_wrapper.py
Normal 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
45
app/image_mutation.py
Normal 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"
|
Reference in New Issue
Block a user