Merge pull request #3024 from coreos-inc/manageable-robots
Manageable robots epic
This commit is contained in:
commit
6c43b7ff0d
24 changed files with 411 additions and 131 deletions
|
@ -527,3 +527,7 @@ class DefaultConfig(ImmutableConfig):
|
||||||
# Defines the number of successive internal errors of a build trigger's build before the
|
# Defines the number of successive internal errors of a build trigger's build before the
|
||||||
# trigger is automatically disabled.
|
# trigger is automatically disabled.
|
||||||
SUCCESSIVE_TRIGGER_INTERNAL_ERROR_DISABLE_THRESHOLD = 5
|
SUCCESSIVE_TRIGGER_INTERNAL_ERROR_DISABLE_THRESHOLD = 5
|
||||||
|
|
||||||
|
# Defines the delay required (in seconds) before the last_accessed field of a user/robot or access
|
||||||
|
# token will be updated after the previous update.
|
||||||
|
LAST_ACCESSED_UPDATE_THRESHOLD_S = 60
|
||||||
|
|
|
@ -440,6 +440,8 @@ class User(BaseModel):
|
||||||
location = CharField(null=True)
|
location = CharField(null=True)
|
||||||
|
|
||||||
maximum_queued_builds_count = IntegerField(null=True)
|
maximum_queued_builds_count = IntegerField(null=True)
|
||||||
|
creation_date = DateTimeField(default=datetime.utcnow, null=True)
|
||||||
|
last_accessed = DateTimeField(null=True, index=True)
|
||||||
|
|
||||||
def delete_instance(self, recursive=False, delete_nullable=False):
|
def delete_instance(self, recursive=False, delete_nullable=False):
|
||||||
# If we are deleting a robot account, only execute the subset of queries necessary.
|
# If we are deleting a robot account, only execute the subset of queries necessary.
|
||||||
|
@ -477,6 +479,12 @@ class User(BaseModel):
|
||||||
Namespace = User.alias()
|
Namespace = User.alias()
|
||||||
|
|
||||||
|
|
||||||
|
class RobotAccountMetadata(BaseModel):
|
||||||
|
robot_account = QuayUserField(index=True, allows_robots=True, unique=True)
|
||||||
|
description = CharField()
|
||||||
|
unstructured_json = JSONField()
|
||||||
|
|
||||||
|
|
||||||
class DeletedNamespace(BaseModel):
|
class DeletedNamespace(BaseModel):
|
||||||
namespace = QuayUserField(index=True, allows_robots=False, unique=True)
|
namespace = QuayUserField(index=True, allows_robots=False, unique=True)
|
||||||
marked = DateTimeField(default=datetime.now)
|
marked = DateTimeField(default=datetime.now)
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Add creation date to User table
|
||||||
|
|
||||||
|
Revision ID: 0cf50323c78b
|
||||||
|
Revises: 87fbbc224f10
|
||||||
|
Create Date: 2018-03-09 13:19:41.903196
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0cf50323c78b'
|
||||||
|
down_revision = '87fbbc224f10'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user', sa.Column('creation_date', sa.DateTime(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('user', 'creation_date')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Add last_accessed field to User table
|
||||||
|
|
||||||
|
Revision ID: 224ce4c72c2f
|
||||||
|
Revises: b547bc139ad8
|
||||||
|
Create Date: 2018-03-12 22:44:07.070490
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '224ce4c72c2f'
|
||||||
|
down_revision = 'b547bc139ad8'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user', sa.Column('last_accessed', sa.DateTime(), nullable=True))
|
||||||
|
op.create_index('user_last_accessed', 'user', ['last_accessed'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('user_last_accessed', table_name='user')
|
||||||
|
op.drop_column('user', 'last_accessed')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Add RobotAccountMetadata table
|
||||||
|
|
||||||
|
Revision ID: b547bc139ad8
|
||||||
|
Revises: 0cf50323c78b
|
||||||
|
Create Date: 2018-03-09 15:50:48.298880
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b547bc139ad8'
|
||||||
|
down_revision = '0cf50323c78b'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from util.migrate import UTF8CharField
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('robotaccountmetadata',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('robot_account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', UTF8CharField(length=255), nullable=False),
|
||||||
|
sa.Column('unstructured_json', sa.Text(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['robot_account_id'], ['user.id'], name=op.f('fk_robotaccountmetadata_robot_account_id_user')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_robotaccountmetadata'))
|
||||||
|
)
|
||||||
|
op.create_index('robotaccountmetadata_robot_account_id', 'robotaccountmetadata', ['robot_account_id'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('robotaccountmetadata')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -1,11 +1,17 @@
|
||||||
from peewee import fn
|
import logging
|
||||||
|
|
||||||
|
from peewee import fn, PeeweeException
|
||||||
from cachetools import lru_cache
|
from cachetools import lru_cache
|
||||||
|
|
||||||
from data.model import DataModelException
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from data.model import DataModelException, config
|
||||||
from data.database import (Repository, User, Team, TeamMember, RepositoryPermission, TeamRole,
|
from data.database import (Repository, User, Team, TeamMember, RepositoryPermission, TeamRole,
|
||||||
Namespace, Visibility, ImageStorage, Image, RepositoryKind,
|
Namespace, Visibility, ImageStorage, Image, RepositoryKind,
|
||||||
db_for_update)
|
db_for_update)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def reduce_as_tree(queries_to_reduce):
|
def reduce_as_tree(queries_to_reduce):
|
||||||
""" This method will split a list of queries into halves recursively until we reach individual
|
""" This method will split a list of queries into halves recursively until we reach individual
|
||||||
queries, at which point it will start unioning the queries, or the already unioned subqueries.
|
queries, at which point it will start unioning the queries, or the already unioned subqueries.
|
||||||
|
@ -164,3 +170,36 @@ def calculate_image_aggregate_size(ancestors_str, image_size, parent_image):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return ancestor_size + image_size
|
return ancestor_size + image_size
|
||||||
|
|
||||||
|
|
||||||
|
def update_last_accessed(token_or_user):
|
||||||
|
""" Updates the `last_accessed` field on the given token or user. If the existing field's value
|
||||||
|
is within the configured threshold, the update is skipped. """
|
||||||
|
threshold = timedelta(seconds=config.app_config.get('LAST_ACCESSED_UPDATE_THRESHOLD_S', 120))
|
||||||
|
if (token_or_user.last_accessed is not None and
|
||||||
|
datetime.utcnow() - token_or_user.last_accessed < threshold):
|
||||||
|
# Skip updating, as we don't want to put undue pressure on the database.
|
||||||
|
return
|
||||||
|
|
||||||
|
model_class = token_or_user.__class__
|
||||||
|
last_accessed = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
(model_class
|
||||||
|
.update(last_accessed=last_accessed)
|
||||||
|
.where(model_class.id == token_or_user.id)
|
||||||
|
.execute())
|
||||||
|
token_or_user.last_accessed = last_accessed
|
||||||
|
except PeeweeException as ex:
|
||||||
|
# If there is any form of DB exception, only fail if strict logging is enabled.
|
||||||
|
strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING')
|
||||||
|
if strict_logging_disabled:
|
||||||
|
data = {
|
||||||
|
'exception': ex,
|
||||||
|
'token_or_user': token_or_user.id,
|
||||||
|
'class': str(model_class),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.exception('update last_accessed for token/user failed', extra=data)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
|
@ -3,10 +3,10 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from cachetools import lru_cache
|
from cachetools import lru_cache
|
||||||
from peewee import PeeweeException
|
|
||||||
|
|
||||||
from data.database import AppSpecificAuthToken, User, db_transaction
|
from data.database import AppSpecificAuthToken, User, db_transaction
|
||||||
from data.model import config
|
from data.model import config
|
||||||
|
from data.model._basequery import update_last_accessed
|
||||||
from util.timedeltastring import convert_to_timedelta
|
from util.timedeltastring import convert_to_timedelta
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -104,23 +104,7 @@ def access_valid_token(token_code):
|
||||||
((AppSpecificAuthToken.expiration > datetime.now()) |
|
((AppSpecificAuthToken.expiration > datetime.now()) |
|
||||||
(AppSpecificAuthToken.expiration >> None)))
|
(AppSpecificAuthToken.expiration >> None)))
|
||||||
.get())
|
.get())
|
||||||
|
update_last_accessed(token)
|
||||||
|
return token
|
||||||
except AppSpecificAuthToken.DoesNotExist:
|
except AppSpecificAuthToken.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
token.last_accessed = datetime.now()
|
|
||||||
|
|
||||||
try:
|
|
||||||
token.save()
|
|
||||||
except PeeweeException as ex:
|
|
||||||
strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING')
|
|
||||||
if strict_logging_disabled:
|
|
||||||
data = {
|
|
||||||
'exception': ex,
|
|
||||||
'token': token.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.exception('update last_accessed for token failed', extra=data)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
|
@ -14,7 +14,8 @@ from data.database import (User, LoginService, FederatedLogin, RepositoryPermiss
|
||||||
EmailConfirmation, Role, db_for_update, random_string_generator,
|
EmailConfirmation, Role, db_for_update, random_string_generator,
|
||||||
UserRegion, ImageStorageLocation,
|
UserRegion, ImageStorageLocation,
|
||||||
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger,
|
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger,
|
||||||
UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace)
|
UserPromptKind, UserPrompt, UserPromptTypes, DeletedNamespace,
|
||||||
|
RobotAccountMetadata)
|
||||||
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
|
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
|
||||||
InvalidUsernameException, InvalidEmailAddressException,
|
InvalidUsernameException, InvalidEmailAddressException,
|
||||||
TooManyLoginAttemptsException, db_transaction,
|
TooManyLoginAttemptsException, db_transaction,
|
||||||
|
@ -172,7 +173,7 @@ def change_username(user_id, new_username):
|
||||||
user = db_for_update(User.select().where(User.id == user_id)).get()
|
user = db_for_update(User.select().where(User.id == user_id)).get()
|
||||||
|
|
||||||
# Rename the robots
|
# Rename the robots
|
||||||
for robot in db_for_update(_list_entity_robots(user.username)):
|
for robot in db_for_update(_list_entity_robots(user.username, include_metadata=False)):
|
||||||
_, robot_shortname = parse_robot_username(robot.username)
|
_, robot_shortname = parse_robot_username(robot.username)
|
||||||
new_robot_name = format_robot_username(new_username, robot_shortname)
|
new_robot_name = format_robot_username(new_username, robot_shortname)
|
||||||
robot.username = new_robot_name
|
robot.username = new_robot_name
|
||||||
|
@ -231,7 +232,7 @@ def update_enabled(user, set_enabled):
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|
||||||
def create_robot(robot_shortname, parent):
|
def create_robot(robot_shortname, parent, description='', unstructured_metadata=None):
|
||||||
(username_valid, username_issue) = validate_username(robot_shortname)
|
(username_valid, username_issue) = validate_username(robot_shortname)
|
||||||
if not username_valid:
|
if not username_valid:
|
||||||
raise InvalidRobotException('The name for the robot \'%s\' is invalid: %s' %
|
raise InvalidRobotException('The name for the robot \'%s\' is invalid: %s' %
|
||||||
|
@ -245,33 +246,48 @@ def create_robot(robot_shortname, parent):
|
||||||
msg = 'Existing robot with name: %s' % username
|
msg = 'Existing robot with name: %s' % username
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
raise InvalidRobotException(msg)
|
raise InvalidRobotException(msg)
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
|
||||||
created = User.create(username=username, robot=True)
|
|
||||||
|
|
||||||
service = LoginService.get(name='quayrobot')
|
service = LoginService.get(name='quayrobot')
|
||||||
|
try:
|
||||||
|
with db_transaction():
|
||||||
|
created = User.create(username=username, robot=True)
|
||||||
password = created.email
|
password = created.email
|
||||||
FederatedLogin.create(user=created, service=service,
|
|
||||||
service_ident=password)
|
|
||||||
|
|
||||||
|
FederatedLogin.create(user=created, service=service, service_ident=password)
|
||||||
|
RobotAccountMetadata.create(robot_account=created, description=description[0:255],
|
||||||
|
unstructured_json=unstructured_metadata or {})
|
||||||
return created, password
|
return created, password
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise DataModelException(ex.message)
|
raise DataModelException(ex.message)
|
||||||
|
|
||||||
|
|
||||||
def get_robot(robot_shortname, parent):
|
def get_or_create_robot_metadata(robot):
|
||||||
|
defaults = dict(description='', unstructured_json='{}')
|
||||||
|
metadata, _ = RobotAccountMetadata.get_or_create(robot_account=robot, defaults=defaults)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def update_robot_metadata(robot, description='', unstructured_json=None):
|
||||||
|
""" Updates the description and user-specified unstructured metadata associated
|
||||||
|
with a robot account to that specified. """
|
||||||
|
metadata = get_or_create_robot_metadata(robot)
|
||||||
|
metadata.description = description
|
||||||
|
metadata.unstructured_json = unstructured_json or metadata.unstructured_json or {}
|
||||||
|
metadata.save()
|
||||||
|
|
||||||
|
|
||||||
|
def get_robot_and_metadata(robot_shortname, parent):
|
||||||
robot_username = format_robot_username(parent.username, robot_shortname)
|
robot_username = format_robot_username(parent.username, robot_shortname)
|
||||||
robot = lookup_robot(robot_username)
|
robot, metadata = lookup_robot_and_metadata(robot_username)
|
||||||
return robot, robot.email
|
return robot, robot.email, metadata
|
||||||
|
|
||||||
|
|
||||||
def lookup_robot(robot_username):
|
def lookup_robot(robot_username):
|
||||||
try:
|
try:
|
||||||
return (User
|
return (User
|
||||||
.select()
|
.select(User, FederatedLogin)
|
||||||
.join(FederatedLogin)
|
.join(FederatedLogin)
|
||||||
.join(LoginService)
|
.join(LoginService)
|
||||||
.where(LoginService.name == 'quayrobot', User.username == robot_username,
|
.where(LoginService.name == 'quayrobot', User.username == robot_username,
|
||||||
|
@ -281,6 +297,11 @@ def lookup_robot(robot_username):
|
||||||
raise InvalidRobotException('Could not find robot with username: %s' % robot_username)
|
raise InvalidRobotException('Could not find robot with username: %s' % robot_username)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_robot_and_metadata(robot_username):
|
||||||
|
robot = lookup_robot(robot_username)
|
||||||
|
return robot, get_or_create_robot_metadata(robot)
|
||||||
|
|
||||||
|
|
||||||
def get_matching_robots(name_prefix, username, limit=10):
|
def get_matching_robots(name_prefix, username, limit=10):
|
||||||
admined_orgs = (_basequery.get_user_organizations(username)
|
admined_orgs = (_basequery.get_user_organizations(username)
|
||||||
.switch(Team)
|
.switch(Team)
|
||||||
|
@ -328,12 +349,15 @@ def verify_robot(robot_username, password):
|
||||||
if not owner.enabled:
|
if not owner.enabled:
|
||||||
raise InvalidRobotException('This user has been disabled. Please contact your administrator.')
|
raise InvalidRobotException('This user has been disabled. Please contact your administrator.')
|
||||||
|
|
||||||
|
# Mark that the robot was accessed.
|
||||||
|
_basequery.update_last_accessed(robot)
|
||||||
|
|
||||||
return robot
|
return robot
|
||||||
|
|
||||||
def regenerate_robot_token(robot_shortname, parent):
|
def regenerate_robot_token(robot_shortname, parent):
|
||||||
robot_username = format_robot_username(parent.username, robot_shortname)
|
robot_username = format_robot_username(parent.username, robot_shortname)
|
||||||
|
|
||||||
robot = lookup_robot(robot_username)
|
robot, metadata = lookup_robot_and_metadata(robot_username)
|
||||||
password = random_string_generator(length=64)()
|
password = random_string_generator(length=64)()
|
||||||
robot.email = password
|
robot.email = password
|
||||||
robot.uuid = str(uuid4())
|
robot.uuid = str(uuid4())
|
||||||
|
@ -345,7 +369,7 @@ def regenerate_robot_token(robot_shortname, parent):
|
||||||
login.save()
|
login.save()
|
||||||
robot.save()
|
robot.save()
|
||||||
|
|
||||||
return robot, password
|
return robot, password, metadata
|
||||||
|
|
||||||
def delete_robot(robot_username):
|
def delete_robot(robot_username):
|
||||||
try:
|
try:
|
||||||
|
@ -362,20 +386,28 @@ def list_namespace_robots(namespace):
|
||||||
return _list_entity_robots(namespace)
|
return _list_entity_robots(namespace)
|
||||||
|
|
||||||
|
|
||||||
def _list_entity_robots(entity_name):
|
def _list_entity_robots(entity_name, include_metadata=True):
|
||||||
""" Return the list of robots for the specified entity. This MUST return a query, not a
|
""" Return the list of robots for the specified entity. This MUST return a query, not a
|
||||||
materialized list so that callers can use db_for_update.
|
materialized list so that callers can use db_for_update.
|
||||||
"""
|
"""
|
||||||
return (User
|
query = (User
|
||||||
.select()
|
.select(User, FederatedLogin)
|
||||||
.join(FederatedLogin)
|
.join(FederatedLogin)
|
||||||
.where(User.robot == True, User.username ** (entity_name + '+%')))
|
.where(User.robot == True, User.username ** (entity_name + '+%')))
|
||||||
|
|
||||||
|
if include_metadata:
|
||||||
|
query = (query.switch(User)
|
||||||
|
.join(RobotAccountMetadata, JOIN_LEFT_OUTER)
|
||||||
|
.select(User, FederatedLogin, RobotAccountMetadata))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
def list_entity_robot_permission_teams(entity_name, include_permissions=False):
|
def list_entity_robot_permission_teams(entity_name, include_permissions=False):
|
||||||
query = (_list_entity_robots(entity_name))
|
query = (_list_entity_robots(entity_name))
|
||||||
|
|
||||||
fields = [User.username, FederatedLogin.service_ident]
|
fields = [User.username, User.creation_date, User.last_accessed, FederatedLogin.service_ident,
|
||||||
|
RobotAccountMetadata.description, RobotAccountMetadata.unstructured_json]
|
||||||
if include_permissions:
|
if include_permissions:
|
||||||
query = (query
|
query = (query
|
||||||
.join(RepositoryPermission, JOIN_LEFT_OUTER,
|
.join(RepositoryPermission, JOIN_LEFT_OUTER,
|
||||||
|
@ -449,6 +481,10 @@ def verify_federated_login(service_id, service_ident):
|
||||||
.switch(FederatedLogin).join(User)
|
.switch(FederatedLogin).join(User)
|
||||||
.where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id)
|
.where(FederatedLogin.service_ident == service_ident, LoginService.name == service_id)
|
||||||
.get())
|
.get())
|
||||||
|
|
||||||
|
# Mark that the user was accessed.
|
||||||
|
_basequery.update_last_accessed(found.user)
|
||||||
|
|
||||||
return found.user
|
return found.user
|
||||||
except FederatedLogin.DoesNotExist:
|
except FederatedLogin.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
@ -714,6 +750,9 @@ def verify_user(username_or_email, password):
|
||||||
.where(User.id == fetched.id)
|
.where(User.id == fetched.id)
|
||||||
.execute())
|
.execute())
|
||||||
|
|
||||||
|
# Mark that the user was accessed.
|
||||||
|
_basequery.update_last_accessed(fetched)
|
||||||
|
|
||||||
# Return the valid user.
|
# Return the valid user.
|
||||||
return fetched
|
return fetched
|
||||||
|
|
||||||
|
|
|
@ -321,7 +321,7 @@ def require_scope(scope_object):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def validate_json_request(schema_name):
|
def validate_json_request(schema_name, optional=False):
|
||||||
def wrapper(func):
|
def wrapper(func):
|
||||||
@add_method_metadata('request_schema', schema_name)
|
@add_method_metadata('request_schema', schema_name)
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
|
@ -330,8 +330,9 @@ def validate_json_request(schema_name):
|
||||||
try:
|
try:
|
||||||
json_data = request.get_json()
|
json_data = request.get_json()
|
||||||
if json_data is None:
|
if json_data is None:
|
||||||
|
if not optional:
|
||||||
raise InvalidRequest('Missing JSON body')
|
raise InvalidRequest('Missing JSON body')
|
||||||
|
else:
|
||||||
validate(json_data, schema)
|
validate(json_data, schema)
|
||||||
return func(self, *args, **kwargs)
|
return func(self, *args, **kwargs)
|
||||||
except ValidationError as ex:
|
except ValidationError as ex:
|
||||||
|
|
|
@ -2,14 +2,31 @@
|
||||||
|
|
||||||
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
|
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
|
||||||
require_user_admin, require_scope, path_param, parse_args,
|
require_user_admin, require_scope, path_param, parse_args,
|
||||||
truthy_bool, query_param)
|
truthy_bool, query_param, validate_json_request)
|
||||||
from endpoints.api.robot_models_pre_oci import pre_oci_model as model
|
from endpoints.api.robot_models_pre_oci import pre_oci_model as model
|
||||||
from endpoints.exception import Unauthorized
|
from endpoints.exception import Unauthorized
|
||||||
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
|
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from util.names import format_robot_username
|
from util.names import format_robot_username
|
||||||
from flask import abort
|
from flask import abort, request
|
||||||
|
|
||||||
|
|
||||||
|
CREATE_ROBOT_SCHEMA = {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Optional data for creating a robot',
|
||||||
|
'properties': {
|
||||||
|
'description': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'Optional text description for the robot',
|
||||||
|
'maxLength': 255,
|
||||||
|
},
|
||||||
|
'unstructured_metadata': {
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Optional unstructured metadata for the robot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def robots_list(prefix, include_permissions=False):
|
def robots_list(prefix, include_permissions=False):
|
||||||
|
@ -38,6 +55,9 @@ class UserRobotList(ApiResource):
|
||||||
'The short name for the robot, without any user or organization prefix')
|
'The short name for the robot, without any user or organization prefix')
|
||||||
class UserRobot(ApiResource):
|
class UserRobot(ApiResource):
|
||||||
""" Resource for managing a user's robots. """
|
""" Resource for managing a user's robots. """
|
||||||
|
schemas = {
|
||||||
|
'CreateRobot': CREATE_ROBOT_SCHEMA,
|
||||||
|
}
|
||||||
|
|
||||||
@require_user_admin
|
@require_user_admin
|
||||||
@nickname('getUserRobot')
|
@nickname('getUserRobot')
|
||||||
|
@ -45,16 +65,23 @@ class UserRobot(ApiResource):
|
||||||
""" Returns the user's robot with the specified name. """
|
""" Returns the user's robot with the specified name. """
|
||||||
parent = get_authenticated_user()
|
parent = get_authenticated_user()
|
||||||
robot = model.get_user_robot(robot_shortname, parent)
|
robot = model.get_user_robot(robot_shortname, parent)
|
||||||
return robot.to_dict()
|
return robot.to_dict(include_metadata=True)
|
||||||
|
|
||||||
@require_user_admin
|
@require_user_admin
|
||||||
@nickname('createUserRobot')
|
@nickname('createUserRobot')
|
||||||
|
@validate_json_request('CreateRobot', optional=True)
|
||||||
def put(self, robot_shortname):
|
def put(self, robot_shortname):
|
||||||
""" Create a new user robot with the specified name. """
|
""" Create a new user robot with the specified name. """
|
||||||
parent = get_authenticated_user()
|
parent = get_authenticated_user()
|
||||||
robot = model.create_user_robot(robot_shortname, parent)
|
create_data = request.get_json() or {}
|
||||||
log_action('create_robot', parent.username, {'robot': robot_shortname})
|
robot = model.create_user_robot(robot_shortname, parent, create_data.get('description'),
|
||||||
return robot.to_dict(), 201
|
create_data.get('unstructured_metadata'))
|
||||||
|
log_action('create_robot', parent.username, {
|
||||||
|
'robot': robot_shortname,
|
||||||
|
'description': create_data.get('description'),
|
||||||
|
'unstructured_metadata': create_data.get('unstructured_metadata'),
|
||||||
|
})
|
||||||
|
return robot.to_dict(include_metadata=True), 201
|
||||||
|
|
||||||
@require_user_admin
|
@require_user_admin
|
||||||
@nickname('deleteUserRobot')
|
@nickname('deleteUserRobot')
|
||||||
|
@ -94,6 +121,9 @@ class OrgRobotList(ApiResource):
|
||||||
@related_user_resource(UserRobot)
|
@related_user_resource(UserRobot)
|
||||||
class OrgRobot(ApiResource):
|
class OrgRobot(ApiResource):
|
||||||
""" Resource for managing an organization's robots. """
|
""" Resource for managing an organization's robots. """
|
||||||
|
schemas = {
|
||||||
|
'CreateRobot': CREATE_ROBOT_SCHEMA,
|
||||||
|
}
|
||||||
|
|
||||||
@require_scope(scopes.ORG_ADMIN)
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
@nickname('getOrgRobot')
|
@nickname('getOrgRobot')
|
||||||
|
@ -102,19 +132,26 @@ class OrgRobot(ApiResource):
|
||||||
permission = AdministerOrganizationPermission(orgname)
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
if permission.can():
|
if permission.can():
|
||||||
robot = model.get_org_robot(robot_shortname, orgname)
|
robot = model.get_org_robot(robot_shortname, orgname)
|
||||||
return robot.to_dict()
|
return robot.to_dict(include_metadata=True)
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
@require_scope(scopes.ORG_ADMIN)
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
@nickname('createOrgRobot')
|
@nickname('createOrgRobot')
|
||||||
|
@validate_json_request('CreateRobot', optional=True)
|
||||||
def put(self, orgname, robot_shortname):
|
def put(self, orgname, robot_shortname):
|
||||||
""" Create a new robot in the organization. """
|
""" Create a new robot in the organization. """
|
||||||
permission = AdministerOrganizationPermission(orgname)
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
if permission.can():
|
if permission.can():
|
||||||
robot = model.create_org_robot(robot_shortname, orgname)
|
create_data = request.get_json() or {}
|
||||||
log_action('create_robot', orgname, {'robot': robot_shortname})
|
robot = model.create_org_robot(robot_shortname, orgname, create_data.get('description'),
|
||||||
return robot.to_dict(), 201
|
create_data.get('unstructured_metadata'))
|
||||||
|
log_action('create_robot', orgname, {
|
||||||
|
'robot': robot_shortname,
|
||||||
|
'description': create_data.get('description'),
|
||||||
|
'unstructured_metadata': create_data.get('unstructured_metadata'),
|
||||||
|
})
|
||||||
|
return robot.to_dict(include_metadata=True), 201
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ from collections import namedtuple
|
||||||
|
|
||||||
from six import add_metaclass
|
from six import add_metaclass
|
||||||
|
|
||||||
|
from endpoints.api import format_date
|
||||||
|
|
||||||
|
|
||||||
class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])):
|
class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])):
|
||||||
"""
|
"""
|
||||||
|
@ -36,24 +38,32 @@ class RobotWithPermissions(
|
||||||
namedtuple('RobotWithPermissions', [
|
namedtuple('RobotWithPermissions', [
|
||||||
'name',
|
'name',
|
||||||
'password',
|
'password',
|
||||||
|
'created',
|
||||||
|
'last_accessed',
|
||||||
'teams',
|
'teams',
|
||||||
'repository_names',
|
'repository_names',
|
||||||
|
'description',
|
||||||
])):
|
])):
|
||||||
"""
|
"""
|
||||||
RobotWithPermissions is a list of robot entries.
|
RobotWithPermissions is a list of robot entries.
|
||||||
:type name: string
|
:type name: string
|
||||||
:type password: string
|
:type password: string
|
||||||
|
:type created: datetime|None
|
||||||
|
:type last_accessed: datetime|None
|
||||||
:type teams: [Team]
|
:type teams: [Team]
|
||||||
:type repository_names: [string]
|
:type repository_names: [string]
|
||||||
|
:type description: string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'token': self.password,
|
'token': self.password,
|
||||||
|
'created': format_date(self.created) if self.created is not None else None,
|
||||||
|
'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None,
|
||||||
'teams': [team.to_dict() for team in self.teams],
|
'teams': [team.to_dict() for team in self.teams],
|
||||||
'repositories': self.repository_names
|
'repositories': self.repository_names,
|
||||||
|
'description': self.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -61,20 +71,35 @@ class Robot(
|
||||||
namedtuple('Robot', [
|
namedtuple('Robot', [
|
||||||
'name',
|
'name',
|
||||||
'password',
|
'password',
|
||||||
|
'created',
|
||||||
|
'last_accessed',
|
||||||
|
'description',
|
||||||
|
'unstructured_metadata',
|
||||||
])):
|
])):
|
||||||
"""
|
"""
|
||||||
Robot represents a robot entity.
|
Robot represents a robot entity.
|
||||||
:type name: string
|
:type name: string
|
||||||
:type password: string
|
:type password: string
|
||||||
|
:type created: datetime|None
|
||||||
|
:type last_accessed: datetime|None
|
||||||
|
:type description: string
|
||||||
|
:type unstructured_metadata: dict
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self, include_metadata=False):
|
||||||
return {
|
data = {
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'token': self.password
|
'token': self.password,
|
||||||
|
'created': format_date(self.created) if self.created is not None else None,
|
||||||
|
'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None,
|
||||||
|
'description': self.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if include_metadata:
|
||||||
|
data['unstructured_metadata'] = self.unstructured_metadata
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@add_metaclass(ABCMeta)
|
@add_metaclass(ABCMeta)
|
||||||
class RobotInterface(object):
|
class RobotInterface(object):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from app import avatar
|
from app import avatar
|
||||||
from data import model
|
from data import model
|
||||||
from data.database import User, FederatedLogin, Team as TeamTable, Repository
|
from data.database import User, FederatedLogin, Team as TeamTable, Repository, RobotAccountMetadata
|
||||||
from endpoints.api.robot_models_interface import (RobotInterface, Robot, RobotWithPermissions, Team,
|
from endpoints.api.robot_models_interface import (RobotInterface, Robot, RobotWithPermissions, Team,
|
||||||
Permission)
|
Permission)
|
||||||
|
|
||||||
|
@ -23,14 +23,21 @@ class RobotPreOCIModel(RobotInterface):
|
||||||
robot_dict = {
|
robot_dict = {
|
||||||
'name': robot_name,
|
'name': robot_name,
|
||||||
'token': robot_tuple.get(FederatedLogin.service_ident),
|
'token': robot_tuple.get(FederatedLogin.service_ident),
|
||||||
|
'created': robot_tuple.get(User.creation_date),
|
||||||
|
'last_accessed': robot_tuple.get(User.last_accessed),
|
||||||
|
'description': robot_tuple.get(RobotAccountMetadata.description),
|
||||||
|
'unstructured_metadata': robot_tuple.get(RobotAccountMetadata.unstructured_json),
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_permissions:
|
if include_permissions:
|
||||||
robot_dict.update({
|
robot_dict.update({
|
||||||
'teams': [],
|
'teams': [],
|
||||||
'repositories': []
|
'repositories': [],
|
||||||
})
|
})
|
||||||
robots[robot_name] = Robot(robot_dict['name'], robot_dict['token'])
|
|
||||||
|
robots[robot_name] = Robot(robot_dict['name'], robot_dict['token'], robot_dict['created'],
|
||||||
|
robot_dict['last_accessed'], robot_dict['description'],
|
||||||
|
robot_dict['unstructured_metadata'])
|
||||||
if include_permissions:
|
if include_permissions:
|
||||||
team_name = robot_tuple.get(TeamTable.name)
|
team_name = robot_tuple.get(TeamTable.name)
|
||||||
repository_name = robot_tuple.get(Repository.name)
|
repository_name = robot_tuple.get(Repository.name)
|
||||||
|
@ -48,40 +55,52 @@ class RobotPreOCIModel(RobotInterface):
|
||||||
if repository_name is not None:
|
if repository_name is not None:
|
||||||
if repository_name not in robot_dict['repositories']:
|
if repository_name not in robot_dict['repositories']:
|
||||||
robot_dict['repositories'].append(repository_name)
|
robot_dict['repositories'].append(repository_name)
|
||||||
robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], robot_dict['teams'],
|
robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'],
|
||||||
robot_dict['repositories'])
|
robot_dict['created'],
|
||||||
|
robot_dict['last_accessed'],
|
||||||
|
robot_dict['teams'],
|
||||||
|
robot_dict['repositories'],
|
||||||
|
robot_dict['description'])
|
||||||
|
|
||||||
return robots.values()
|
return robots.values()
|
||||||
|
|
||||||
def regenerate_user_robot_token(self, robot_shortname, owning_user):
|
def regenerate_user_robot_token(self, robot_shortname, owning_user):
|
||||||
robot, password = model.user.regenerate_robot_token(robot_shortname, owning_user)
|
robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, owning_user)
|
||||||
return Robot(robot.username, password)
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
metadata.description, metadata.unstructured_json)
|
||||||
|
|
||||||
def regenerate_org_robot_token(self, robot_shortname, orgname):
|
def regenerate_org_robot_token(self, robot_shortname, orgname):
|
||||||
parent = model.organization.get_organization(orgname)
|
parent = model.organization.get_organization(orgname)
|
||||||
robot, password = model.user.regenerate_robot_token(robot_shortname, parent)
|
robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, parent)
|
||||||
return Robot(robot.username, password)
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
metadata.description, metadata.unstructured_json)
|
||||||
|
|
||||||
def delete_robot(self, robot_username):
|
def delete_robot(self, robot_username):
|
||||||
model.user.delete_robot(robot_username)
|
model.user.delete_robot(robot_username)
|
||||||
|
|
||||||
def create_user_robot(self, robot_shortname, owning_user):
|
def create_user_robot(self, robot_shortname, owning_user, description, unstructured_metadata):
|
||||||
robot, password = model.user.create_robot(robot_shortname, owning_user)
|
robot, password = model.user.create_robot(robot_shortname, owning_user, description or '',
|
||||||
return Robot(robot.username, password)
|
unstructured_metadata)
|
||||||
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
description or '', unstructured_metadata)
|
||||||
|
|
||||||
def create_org_robot(self, robot_shortname, orgname):
|
def create_org_robot(self, robot_shortname, orgname, description, unstructured_metadata):
|
||||||
parent = model.organization.get_organization(orgname)
|
parent = model.organization.get_organization(orgname)
|
||||||
robot, password = model.user.create_robot(robot_shortname, parent)
|
robot, password = model.user.create_robot(robot_shortname, parent, description or '',
|
||||||
return Robot(robot.username, password)
|
unstructured_metadata)
|
||||||
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
description or '', unstructured_metadata)
|
||||||
|
|
||||||
def get_org_robot(self, robot_shortname, orgname):
|
def get_org_robot(self, robot_shortname, orgname):
|
||||||
parent = model.organization.get_organization(orgname)
|
parent = model.organization.get_organization(orgname)
|
||||||
robot, password = model.user.get_robot(robot_shortname, parent)
|
robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, parent)
|
||||||
return Robot(robot.username, password)
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
metadata.description, metadata.unstructured_json)
|
||||||
|
|
||||||
def get_user_robot(self, robot_shortname, owning_user):
|
def get_user_robot(self, robot_shortname, owning_user):
|
||||||
robot, password = model.user.get_robot(robot_shortname, owning_user)
|
robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, owning_user)
|
||||||
return Robot(robot.username, password)
|
return Robot(robot.username, password, robot.creation_date, robot.last_accessed,
|
||||||
|
metadata.description, metadata.unstructured_json)
|
||||||
|
|
||||||
|
|
||||||
pre_oci_model = RobotPreOCIModel()
|
pre_oci_model = RobotPreOCIModel()
|
||||||
|
|
38
endpoints/api/test/test_robot.py
Normal file
38
endpoints/api/test/test_robot.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from endpoints.api import api
|
||||||
|
from endpoints.api.test.shared import conduct_api_call
|
||||||
|
from endpoints.api.robot import UserRobot, OrgRobot
|
||||||
|
from endpoints.test.shared import client_with_identity
|
||||||
|
|
||||||
|
from test.test_ldap import mock_ldap
|
||||||
|
|
||||||
|
from test.fixtures import *
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('endpoint', [
|
||||||
|
UserRobot,
|
||||||
|
OrgRobot,
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
{},
|
||||||
|
{'description': 'this is a description'},
|
||||||
|
{'unstructured_metadata': {'foo': 'bar'}},
|
||||||
|
{'description': 'this is a description', 'unstructured_metadata': {'foo': 'bar'}},
|
||||||
|
])
|
||||||
|
def test_create_robot_with_metadata(endpoint, body, client):
|
||||||
|
with client_with_identity('devtable', client) as cl:
|
||||||
|
# Create the robot with the specified body.
|
||||||
|
conduct_api_call(cl, endpoint, 'PUT', {'orgname': 'buynlarge', 'robot_shortname': 'somebot'},
|
||||||
|
body, expected_code=201)
|
||||||
|
|
||||||
|
# Ensure the create succeeded.
|
||||||
|
resp = conduct_api_call(cl, endpoint, 'GET', {
|
||||||
|
'orgname': 'buynlarge',
|
||||||
|
'robot_shortname': 'somebot',
|
||||||
|
})
|
||||||
|
|
||||||
|
body = body or {}
|
||||||
|
assert resp.json['description'] == (body.get('description') or '')
|
||||||
|
assert resp.json['unstructured_metadata'] == (body.get('unstructured_metadata') or {})
|
|
@ -268,17 +268,6 @@ class OCIAppModel(AppRegistryDataInterface):
|
||||||
channel = oci_model.channel.create_or_update_channel(repo, channel_name, release)
|
channel = oci_model.channel.create_or_update_channel(repo, channel_name, release)
|
||||||
return ChannelView(current=channel.linked_tag.name, name=channel.name)
|
return ChannelView(current=channel.linked_tag.name, name=channel.name)
|
||||||
|
|
||||||
def get_user(self, username, password):
|
|
||||||
err_msg = None
|
|
||||||
if parse_robot_username(username) is not None:
|
|
||||||
try:
|
|
||||||
user = data.model.user.verify_robot(username, password)
|
|
||||||
except data.model.InvalidRobotException as exc:
|
|
||||||
return (None, exc.message)
|
|
||||||
else:
|
|
||||||
user, err_msg = authentication.verify_and_link_user(username, password)
|
|
||||||
return (user, err_msg)
|
|
||||||
|
|
||||||
def get_blob_locations(self, digest):
|
def get_blob_locations(self, digest):
|
||||||
return oci_model.blob.get_blob_locations(digest)
|
return oci_model.blob.get_blob_locations(digest)
|
||||||
|
|
||||||
|
|
|
@ -160,22 +160,6 @@ class DockerRegistryV1DataInterface(object):
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def load_token(self, token):
|
|
||||||
"""
|
|
||||||
Loads the data associated with the given (deprecated) access token, and, if
|
|
||||||
found returns True.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def verify_robot(self, username, token):
|
|
||||||
"""
|
|
||||||
Returns True if the given robot username and token match an existing robot
|
|
||||||
account.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def change_user_password(self, user, new_password):
|
def change_user_password(self, user, new_password):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -137,19 +137,6 @@ class PreOCIModel(DockerRegistryV1DataInterface):
|
||||||
def delete_tag(self, namespace_name, repo_name, tag_name):
|
def delete_tag(self, namespace_name, repo_name, tag_name):
|
||||||
model.tag.delete_tag(namespace_name, repo_name, tag_name)
|
model.tag.delete_tag(namespace_name, repo_name, tag_name)
|
||||||
|
|
||||||
def load_token(self, token):
|
|
||||||
try:
|
|
||||||
model.token.load_token_data(token)
|
|
||||||
return True
|
|
||||||
except model.InvalidTokenException:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def verify_robot(self, username, token):
|
|
||||||
try:
|
|
||||||
return bool(model.user.verify_robot(username, token))
|
|
||||||
except model.InvalidRobotException:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def change_user_password(self, user, new_password):
|
def change_user_password(self, user, new_password):
|
||||||
model.user.change_password(user, new_password)
|
model.user.change_password(user, new_password)
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,14 @@
|
||||||
Choose a name to inform your teammates
|
Choose a name to inform your teammates
|
||||||
about this {{ entityTitle }}. Must match {{ entityNameRegex }}.
|
about this {{ entityTitle }}. Must match {{ entityNameRegex }}.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="allowEntityDescription" style="margin-top: 20px;">
|
||||||
|
<label>Provide an optional description for your new {{ entityTitle }}:</label>
|
||||||
|
<input type="text" class="form-control" ng-model="entityDescription" max-length="255">
|
||||||
|
<div class="help-text">
|
||||||
|
Enter a description to provide extran information to your teammates about this {{ entityTitle }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div> <!-- /.modal-body -->
|
</div> <!-- /.modal-body -->
|
||||||
<div class="modal-footer" ng-show="view == 'setperms'">
|
<div class="modal-footer" ng-show="view == 'setperms'">
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
<div class="create-entity-dialog" info="info" entity-title="robot account"
|
<div class="create-entity-dialog" info="info" entity-title="robot account"
|
||||||
entity-kind="robot"
|
entity-kind="robot"
|
||||||
entity-icon="ci-robot" entity-name-regex="{{ ROBOT_PATTERN }}"
|
entity-icon="ci-robot" entity-name-regex="{{ ROBOT_PATTERN }}"
|
||||||
entity-create-requested="createRobot(name, callback)"
|
allow-entity-description="true"
|
||||||
|
entity-create-requested="createRobot(name, description, callback)"
|
||||||
entity-create-completed="robotFinished(entity)"></div>
|
entity-create-completed="robotFinished(entity)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -36,10 +36,17 @@
|
||||||
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
|
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
|
||||||
<a ng-click="TableService.orderBy('name', options)">Robot Account Name</a>
|
<a ng-click="TableService.orderBy('name', options)">Robot Account Name</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td>Description</td>
|
||||||
<td ng-if="organization" ng-class="TableService.tablePredicateClass('teams_string', options.predicate, options.reverse)">
|
<td ng-if="organization" ng-class="TableService.tablePredicateClass('teams_string', options.predicate, options.reverse)">
|
||||||
<a ng-click="TableService.orderBy('teams_string', options)">Teams</a>
|
<a ng-click="TableService.orderBy('teams_string', options)">Teams</a>
|
||||||
</td>
|
</td>
|
||||||
<td>Repositories</td>
|
<td>Repositories</td>
|
||||||
|
<td ng-class="TableService.tablePredicateClass('created_datetime', options.predicate, options.reverse)">
|
||||||
|
<a ng-click="TableService.orderBy('created_datetime', options)">Created</a>
|
||||||
|
</td>
|
||||||
|
<td ng-class="TableService.tablePredicateClass('last_accessed_datetime', options.predicate, options.reverse)">
|
||||||
|
<a ng-click="TableService.orderBy('last_accessed_datetime', options)">Last Accessed</a>
|
||||||
|
</td>
|
||||||
<td class="options-col"></td>
|
<td class="options-col"></td>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
@ -50,6 +57,10 @@
|
||||||
<span class="prefix" bo-text="getPrefix(robotInfo.name) + '+'"></span><span bo-text="getShortenedName(robotInfo.name)"></span>
|
<span class="prefix" bo-text="getPrefix(robotInfo.name) + '+'"></span><span bo-text="getShortenedName(robotInfo.name)"></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="empty" bo-if="!robotInfo.description">(None)</span>
|
||||||
|
<span bo-if="robotInfo.description">{{ ::robotInfo.description }}</span>
|
||||||
|
</td>
|
||||||
<td bo-if="organization">
|
<td bo-if="organization">
|
||||||
<span class="empty" bo-if="robotInfo.teams.length == 0">
|
<span class="empty" bo-if="robotInfo.teams.length == 0">
|
||||||
No teams
|
No teams
|
||||||
|
@ -78,6 +89,12 @@
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<time-ago datetime="robotInfo.created"></time-ago>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<time-ago datetime="robotInfo.last_accessed"></time-ago>
|
||||||
|
</td>
|
||||||
<td class="options-col">
|
<td class="options-col">
|
||||||
<span class="cor-options-menu">
|
<span class="cor-options-menu">
|
||||||
<span class="cor-option" option-click="showRobot(robotInfo)">
|
<span class="cor-option" option-click="showRobot(robotInfo)">
|
||||||
|
|
|
@ -15,6 +15,7 @@ angular.module('quay').directive('createEntityDialog', function () {
|
||||||
'entityTitle': '@entityTitle',
|
'entityTitle': '@entityTitle',
|
||||||
'entityIcon': '@entityIcon',
|
'entityIcon': '@entityIcon',
|
||||||
'entityNameRegex': '@entityNameRegex',
|
'entityNameRegex': '@entityNameRegex',
|
||||||
|
'allowEntityDescription': '@allowEntityDescription',
|
||||||
|
|
||||||
'entityCreateRequested': '&entityCreateRequested',
|
'entityCreateRequested': '&entityCreateRequested',
|
||||||
'entityCreateCompleted': '&entityCreateCompleted'
|
'entityCreateCompleted': '&entityCreateCompleted'
|
||||||
|
@ -41,6 +42,7 @@ angular.module('quay').directive('createEntityDialog', function () {
|
||||||
|
|
||||||
$scope.show = function() {
|
$scope.show = function() {
|
||||||
$scope.entityName = null;
|
$scope.entityName = null;
|
||||||
|
$scope.entityDescription = null;
|
||||||
$scope.entity = null;
|
$scope.entity = null;
|
||||||
$scope.entityForPermissions = null;
|
$scope.entityForPermissions = null;
|
||||||
$scope.creating = false;
|
$scope.creating = false;
|
||||||
|
@ -67,6 +69,7 @@ angular.module('quay').directive('createEntityDialog', function () {
|
||||||
$scope.view = 'creating';
|
$scope.view = 'creating';
|
||||||
$scope.entityCreateRequested({
|
$scope.entityCreateRequested({
|
||||||
'name': $scope.entityName,
|
'name': $scope.entityName,
|
||||||
|
'description': $scope.entityDescription,
|
||||||
'callback': entityCreateCallback
|
'callback': entityCreateCallback
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,7 @@ angular.module('quay').directive('createRobotDialog', function () {
|
||||||
$scope.robotCreated({'robot': robot});
|
$scope.robotCreated({'robot': robot});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.createRobot = function(name, callback) {
|
$scope.createRobot = function(name, description, callback) {
|
||||||
var organization = $scope.info.namespace;
|
var organization = $scope.info.namespace;
|
||||||
if (!UserService.isOrganization(organization)) {
|
if (!UserService.isOrganization(organization)) {
|
||||||
organization = null;
|
organization = null;
|
||||||
|
@ -29,11 +29,15 @@ angular.module('quay').directive('createRobotDialog', function () {
|
||||||
'robot_shortname': name
|
'robot_shortname': name
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
'description': description
|
||||||
|
};
|
||||||
|
|
||||||
var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() {
|
var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() {
|
||||||
callback(null);
|
callback(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
ApiService.createRobot(organization, null, params).then(function(resp) {
|
ApiService.createRobot(organization, data, params).then(function(resp) {
|
||||||
callback(resp);
|
callback(resp);
|
||||||
}, errorDisplay);
|
}, errorDisplay);
|
||||||
};
|
};
|
||||||
|
|
|
@ -39,6 +39,9 @@ angular.module('quay').directive('robotsManager', function () {
|
||||||
robot['teams_string'] = robot.teams.map(function(team) {
|
robot['teams_string'] = robot.teams.map(function(team) {
|
||||||
return team['name'] || '';
|
return team['name'] || '';
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
|
robot['created_datetime'] = robot.created ? TableService.getReversedTimestamp(robot.created) : null;
|
||||||
|
robot['last_accessed_datetime'] = robot.last_accessed ? TableService.getReversedTimestamp(robot.last_accessed) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.orderedRobots = TableService.buildOrderedItems(robots, $scope.options,
|
$scope.orderedRobots = TableService.buildOrderedItems(robots, $scope.options,
|
||||||
|
|
Binary file not shown.
|
@ -66,6 +66,7 @@ INTERNAL_ONLY_PROPERTIES = {
|
||||||
'LOCAL_OAUTH_HANDLER',
|
'LOCAL_OAUTH_HANDLER',
|
||||||
'USE_CDN',
|
'USE_CDN',
|
||||||
'ANALYTICS_TYPE',
|
'ANALYTICS_TYPE',
|
||||||
|
'LAST_ACCESSED_UPDATE_THRESHOLD_S',
|
||||||
|
|
||||||
'EXCEPTION_LOG_TYPE',
|
'EXCEPTION_LOG_TYPE',
|
||||||
'SENTRY_DSN',
|
'SENTRY_DSN',
|
||||||
|
|
Reference in a new issue