Skip to content

Commit 1331eb3

Browse files
authored
feat: add openstack endpoint for user and project self service (#20)
2 parents 9b2adf4 + 34fcebf commit 1331eb3

File tree

21 files changed

+624
-30
lines changed

21 files changed

+624
-30
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ db.sqlite3
66
*.pyc
77
.token
88
adminrc
9-
.headscale
9+
.prod
1010
celerybeat-schedule.*

Makefile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ init:
2121
@echo "MAIL_SERVER=maildev" >> .env
2222
@echo "MAIL_USERNAME=dev@laboinfra.net" >> .env
2323
@echo "MAIL_STARTTLS=no" >> .env
24-
@cat .headscale >> .env
25-
@echo "" >> .env
2624

2725
@echo "Create keystone adminrc"
2826
@echo "export OS_USERNAME=admin" > adminrc
@@ -37,10 +35,18 @@ init:
3735
@cat adminrc >> .env
3836
@sed -i 's/export //g' .env
3937

38+
@if [ -f .prod ]; then \
39+
echo "Replace .env with .prod"; \
40+
cp .prod .env; \
41+
fi
42+
4043
@echo "Create Install libs for api"
4144
poetry install
4245
@echo "Run database migration"
4346
poetry run alembic upgrade head
47+
48+
49+
admin:
4450
@echo "Create default superuser"
4551
poetry run python -m fob_api admin@laboinfra.net admin admin
4652

dev.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
This is a development script to write the future function to implement on the api
3+
4+
Objective final for api with all function below:
5+
- Sync the database of the api with the openstack keystone service
6+
- Enable user to add user to project it owns
7+
- Enable user to have dynamic quota for each project
8+
- Enable user to share quota with other project
9+
10+
Currently this script is used to sync the database of the api with the openstack keystone service on following aspects: user, project, project user membership, user quota, project quota
11+
"""
12+
13+
import urllib3
14+
15+
urllib3.disable_warnings()
16+
17+
from rich import print
18+
from rich.traceback import install
19+
install(show_locals=True)
20+
from sqlmodel import select, Session
21+
from fob_api import openstack, engine
22+
from fob_api.models.database import QuotaType, UserQuota, UserQuotaShare, Project, ProjectUserMembership, User
23+
24+
keystone_client = openstack.get_keystone_client()
25+
26+
import random
27+
import string
28+
random_password = lambda: "".join(random.choices(string.ascii_letters + string.digits, k=16))
29+
30+
BANNED_NAMES = ["admin", "serivce"]
31+
OPENSTACK_ROLE_MEMBER = keystone_client.roles.find(name="member")
32+
33+
with Session(engine) as session:
34+
35+
# TODO
36+
# Creation
37+
# 1 create user
38+
db_user: list[User] = session.exec(select(User)).all()
39+
openstack_users = keystone_client.users.list()
40+
41+
db_user_names = [user.username for user in db_user if user.username not in BANNED_NAMES]
42+
openstack_user_names = [user.name for user in openstack_users if user.name not in BANNED_NAMES]
43+
44+
user_to_create = [user for user in db_user if user.username not in openstack_user_names and user.username not in BANNED_NAMES]
45+
user_to_delete = [user for user in openstack_users if user.name not in db_user_names and user.name not in BANNED_NAMES]
46+
47+
print("Creating missing users with random password:")
48+
for user in user_to_create:
49+
print(f"Creating user: {user.username}")
50+
keystone_client.users.create(name=user.username, password=random_password(), domain="default", enabled=True)
51+
print(f"Done")
52+
53+
# 2 create project
54+
55+
db_project: list[Project] = session.exec(select(Project)).all()
56+
openstack_projects = keystone_client.projects.list()
57+
58+
db_project_names = [project.name for project in db_project]
59+
openstack_project_names = [project.name for project in openstack_projects]
60+
61+
project_to_create = [project for project in db_project if project.name not in openstack_project_names and project.name not in BANNED_NAMES]
62+
project_to_delete = [project for project in openstack_projects if project.name not in db_project_names and project.name not in BANNED_NAMES]
63+
64+
print("Creating missing projects:")
65+
for project in project_to_create:
66+
user = session.exec(select(User).where(User.id == project.owner_id)).first()
67+
print(f"Creating project: {project.name} with owner: {user.username}")
68+
keystone_client.projects.create(name=project.name, domain="default", enabled=True)
69+
print("Done")
70+
71+
# 3 assign user to project
72+
db_assignments: list[ProjectUserMembership] = session.exec(select(ProjectUserMembership)).all()
73+
db_project: list[Project] = session.exec(select(Project)).all()
74+
75+
# get required assignments from db
76+
merged_db_assignments = {}
77+
for project in db_project:
78+
if project.name not in merged_db_assignments:
79+
merged_db_assignments[project.name] = set()
80+
user = session.exec(select(User).where(User.id == project.owner_id)).first()
81+
merged_db_assignments[project.name].add(user.username)
82+
83+
for assignment in db_assignments:
84+
user = session.exec(select(User).where(User.id == assignment.user_id)).first()
85+
project = session.exec(select(Project).where(Project.id == assignment.project_id)).first()
86+
merged_db_assignments[project.name].add(user.username)
87+
88+
# get assignment from openstack
89+
merged_openstack_assignments = {}
90+
for assignment in keystone_client.role_assignments.list():
91+
if "project" not in assignment.scope:
92+
continue
93+
94+
project = keystone_client.projects.get(assignment.scope["project"]["id"]).name
95+
username = keystone_client.users.get(assignment.user["id"]).name
96+
97+
if project in BANNED_NAMES or username in BANNED_NAMES:
98+
continue
99+
100+
if project not in merged_openstack_assignments:
101+
merged_openstack_assignments[project] = set()
102+
merged_openstack_assignments[project].add(username)
103+
104+
# give permission based on required assignments on db
105+
print("Assigning missing users to projects:")
106+
for project_name, users in merged_db_assignments.items():
107+
for user in users:
108+
if project_name in merged_openstack_assignments and user in merged_openstack_assignments[project_name]:
109+
continue
110+
print(f"\t user: {user} to project: {project_name}")
111+
openstack_user = keystone_client.users.find(name=user)
112+
openstack_project = keystone_client.projects.find(name=project_name)
113+
keystone_client.roles.grant(role=OPENSTACK_ROLE_MEMBER, user=openstack_user, project=openstack_project)
114+
print("Done")
115+
116+
# 4 assign quota to project
117+
users = session.exec(select(User).where(User.username != "admin")).all()
118+
for user in users:
119+
## Calculate all quota for user with shared quota
120+
# extract max quota for user
121+
user_max_quota_dict = {k: 0 for k in QuotaType}
122+
for q in session.exec(select(UserQuota).where(UserQuota.user_id == user.id)).all():
123+
user_max_quota_dict[QuotaType.from_str(q.type)] += q.quantity
124+
print(f"Max quota for user: {user.username}")
125+
print(user_max_quota_dict)
126+
127+
# extract quota who user share with project
128+
user_shared_quota_dict = {k: 0 for k in QuotaType}
129+
for q in session.exec(select(UserQuotaShare).where(UserQuotaShare.user_id == user.id)).all():
130+
user_shared_quota_dict[QuotaType.from_str(q.type)] += q.quantity
131+
132+
print(f"Shared quota for user: {user.username}")
133+
print(user_shared_quota_dict)
134+
135+
# calculate quota left for user
136+
user_left_quota_dict = {k: 0 for k in QuotaType}
137+
for k in user_max_quota_dict:
138+
user_left_quota_dict[k] = user_max_quota_dict[k] - user_shared_quota_dict[k]
139+
print(f"Left quota for user: {user.username}")
140+
print(user_left_quota_dict)
141+
142+
# make inverstigation on limits system and create a nova endpoint to test quota assignement
143+
#print(keystone_client.limits.list())
144+
# assign quota to project
145+
#for project in session.exec(select(Project)).all():
146+
# project_quota_to_apply_dict = {k: 0 for k in QuotaType}
147+
# for q in session.exec(select(UserQuotaShare).where(UserQuotaShare.project_id == project.id)).all():
148+
# project_quota_to_apply_dict[QuotaType.from_str(q.type)] += q.quantity
149+
# print(f"Quota to apply for project: {project.name}")
150+
# print(project_quota_to_apply_dict)
151+
# openstack_project = keystone_client.projects.find(name=project.name)
152+
# for k, v in project_quota_to_apply_dict.items():
153+
# #keystone_client.projects.update_quota(openstack_project, k.value, v)
154+
155+
156+
# Deletion
157+
# try remove quota from project WARNING: check if quota is not used Or how can react nova if set under current usage
158+
159+
# try remove user from project cant remove if user is owner
160+
print("Removing users from projects:")
161+
for project_name, users in merged_openstack_assignments.items():
162+
for user in users:
163+
if project_name not in merged_db_assignments or user not in merged_db_assignments[project_name]:
164+
print(f"\t user: {user} from project: {project_name}")
165+
openstack_user = keystone_client.users.find(name=user)
166+
openstack_project = keystone_client.projects.find(name=project_name)
167+
keystone_client.roles.revoke(role=OPENSTACK_ROLE_MEMBER, user=openstack_user, project=openstack_project)
168+
169+
# try remove project
170+
print("Removing projects:")
171+
for project in project_to_delete:
172+
if project.name in BANNED_NAMES:
173+
continue
174+
print(f"Removing project: {project.name}")
175+
openstack_project = keystone_client.projects.find(name=project.name)
176+
keystone_client.projects.delete(openstack_project)
177+
178+
179+
# try remove user
180+
print("Removing users:")
181+
for user in user_to_delete:
182+
if user.name in BANNED_NAMES:
183+
continue
184+
print(f"Removing user: {user.name}")
185+
openstack_user = keystone_client.users.find(name=user.name)
186+
keystone_client.users.delete(openstack_user)

fob_api/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
from .lib.headscale import HeadScale
44
from .vpn import headscale_driver
55
from . import mail
6+
from uuid import uuid4
7+
from random import choices
8+
from string import ascii_letters, digits
9+
10+
import urllib3
11+
12+
urllib3.disable_warnings()
13+
14+
random_end_uid = lambda: str(uuid4()).split('-')[-1]
15+
random_password = lambda: "".join(choices(ascii_letters + digits, k=16))
16+
17+
# TODO move to config
18+
OPENSTACK_DOMAIN_ID = "cbd81d851be944aeb22873cc9919c82c"
19+
OPENSTACK_ROLE_MEMBER_ID = "33db095e110f421487ad379176873aff"
620

721
# Initialize configuration and database engine
822
engine = init_engine()

fob_api/lib/headscale/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,19 @@ def create(self, name: str) -> 'User':
4343
server_reply = requests.post(f'{self.__path__}', headers=self.__driver__.headers, json={'name': name})
4444
if server_reply.status_code != 200:
4545
raise Exception(f'Error: {server_reply.status_code} - {server_reply.text}')
46-
return User(__driver__=self.__driver__, **server_reply.json())
46+
return User(__driver__=self.__driver__, **server_reply.json()["user"])
4747

4848
def get(self, name: str) -> 'User':
4949
server_reply = requests.get(f'{self.__path__}/{name}', headers=self.__driver__.headers)
5050
if server_reply.status_code != 200:
5151
raise Exception(f'Error: {server_reply.status_code} - {server_reply.text}')
52-
return User(__driver__=self.__driver__, **server_reply.json())
52+
return User(__driver__=self.__driver__, **server_reply.json()["user"])
5353

5454
def rename(self, name: str, new_name: str) -> 'User':
5555
server_reply = requests.post(f'{self.__path__}/{name}/rename/{new_name}', headers=self.__driver__.headers)
5656
if server_reply.status_code != 200:
5757
raise Exception(f'Error: {server_reply.status_code} - {server_reply.text}')
58-
return User(__driver__=self.__driver__, **server_reply.json())
58+
return User(__driver__=self.__driver__, **server_reply.json()["user"])
5959

6060
def delete(self, name: str):
6161
server_reply = requests.delete(f'{self.__path__}/{name}', headers=self.__driver__.headers)

fob_api/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
app.include_router(routes.users_router)
1010
app.include_router(routes.vpn_router)
1111
app.include_router(routes.headscale_router)
12+
app.include_router(routes.openstack_router)

fob_api/models/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@
2222
UserResetPasswordResponse,
2323
UserMeshGroup
2424
)
25+
from .openstack import (
26+
OpenStackProject,
27+
OpenStackProjectCreate,
28+
OpenStackUserPassword
29+
)

fob_api/models/api/openstack.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import List
2+
from pydantic import BaseModel
3+
4+
class OpenStackProjectCreate(BaseModel):
5+
name: str
6+
7+
class OpenStackProject(OpenStackProjectCreate):
8+
id: int
9+
10+
class OpenStackUserPassword(BaseModel):
11+
username: str
12+
password: str

fob_api/models/database/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@
88
HeadScalePolicyTagOwnerMember,
99
HeadScalePolicyHost
1010
)
11+
from .openstack import (
12+
QuotaType,
13+
UserQuota,
14+
Project,
15+
ProjectUserMembership,
16+
UserQuotaShare
17+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from enum import Enum as PyEnum
2+
from datetime import datetime
3+
from sqlmodel import Field, SQLModel, Column
4+
from sqlalchemy.sql.sqltypes import Enum
5+
6+
class QuotaType(str, PyEnum):
7+
"""
8+
Enum for quota share types available in this API
9+
"""
10+
CPU = "cpu"
11+
MEMORY = "mem"
12+
STORAGE = "sto"
13+
14+
@classmethod
15+
def from_str(cls, value: str) -> "QuotaType":
16+
return cls(value)
17+
18+
class UserQuota(SQLModel, table=True):
19+
"""
20+
Represents one quota adjustment for a user
21+
"""
22+
__tablename__ = "openstack_user_quota"
23+
24+
id: int = Field(primary_key=True)
25+
user_id: int = Field(foreign_key="user.id") # user whose quota is being adjusted
26+
comment: str = Field(nullable=True) # reason for this quota adjustment
27+
quantity: int = Field() # + for increase, - for decrease followed by a number
28+
type: QuotaType = Field(sa_column=Column(Enum(QuotaType))) # 'cpu', 'memory', 'storage'
29+
created_at: datetime = Field(default=datetime.now())
30+
31+
class Project(SQLModel, table=True):
32+
"""
33+
Represents a project in OpenStack and its owned by a user
34+
"""
35+
__tablename__ = "openstack_project"
36+
37+
id: int = Field(primary_key=True)
38+
name: str = Field() # name of the project
39+
owner_id: int = Field(foreign_key="user.id") # user who owns this project
40+
created_at: datetime = Field(default=datetime.now())
41+
42+
class ProjectUserMembership(SQLModel, table=True):
43+
"""
44+
Represents a user's membership in a project
45+
"""
46+
__tablename__ = "openstack_project_user_membership"
47+
48+
id: int = Field(primary_key=True)
49+
user_id: int = Field(foreign_key="user.id")
50+
project_id: int = Field(foreign_key="openstack_project.id")
51+
created_at: datetime = Field(default=datetime.now())
52+
53+
class UserQuotaShare(SQLModel, table=True):
54+
"""
55+
Represents a user's shared quota on a project
56+
"""
57+
__tablename__ = "openstack_user_quota_share"
58+
59+
id: int = Field(primary_key=True)
60+
user_id: int = Field(foreign_key="user.id")
61+
project_id: int = Field(foreign_key="openstack_project.id")
62+
comment: str = Field(nullable=True)
63+
quantity: int = Field()
64+
type: QuotaType = Field(sa_column=Column(Enum(QuotaType)))
65+
created_at: datetime = Field(default=datetime.now())

0 commit comments

Comments
 (0)