Coverage for watcher/api/controllers/v1/service.py: 91%

126 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-17 12:22 +0000

1# -*- encoding: utf-8 -*- 

2# Copyright (c) 2016 Servionica 

3# 

4# Licensed under the Apache License, Version 2.0 (the "License"); 

5# you may not use this file except in compliance with the License. 

6# You may obtain a copy of the License at 

7# 

8# http://www.apache.org/licenses/LICENSE-2.0 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 

13# implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16 

17""" 

18Service mechanism provides ability to monitor Watcher services state. 

19""" 

20 

21import datetime 

22from oslo_config import cfg 

23from oslo_log import log 

24from oslo_utils import timeutils 

25import pecan 

26from pecan import rest 

27from wsme import types as wtypes 

28import wsmeext.pecan as wsme_pecan 

29 

30from watcher.api.controllers import base 

31from watcher.api.controllers import link 

32from watcher.api.controllers.v1 import collection 

33from watcher.api.controllers.v1 import utils as api_utils 

34from watcher.common import context 

35from watcher.common import exception 

36from watcher.common import policy 

37from watcher import objects 

38 

39 

40CONF = cfg.CONF 

41LOG = log.getLogger(__name__) 

42 

43 

44def hide_fields_in_newer_versions(obj): 

45 """This method hides fields that were added in newer API versions. 

46 

47 Certain node fields were introduced at certain API versions. 

48 These fields are only made available when the request's API version 

49 matches or exceeds the versions when these fields were introduced. 

50 """ 

51 pass 

52 

53 

54class Service(base.APIBase): 

55 """API representation of a service. 

56 

57 This class enforces type checking and value constraints, and converts 

58 between the internal object model and the API representation of a service. 

59 """ 

60 

61 _status = None 

62 _context = context.RequestContext(is_admin=True) 

63 

64 def _get_status(self): 

65 return self._status 

66 

67 def _set_status(self, id): 

68 service = objects.Service.get(pecan.request.context, id) 

69 last_heartbeat = (service.last_seen_up or service.updated_at or 

70 service.created_at) 

71 if isinstance(last_heartbeat, str): 71 ↛ 75line 71 didn't jump to line 75 because the condition on line 71 was never true

72 # NOTE(russellb) If this service came in over rpc via 

73 # conductor, then the timestamp will be a string and needs to be 

74 # converted back to a datetime. 

75 last_heartbeat = timeutils.parse_strtime(last_heartbeat) 

76 else: 

77 # Objects have proper UTC timezones, but the timeutils comparison 

78 # below does not (and will fail) 

79 last_heartbeat = last_heartbeat.replace(tzinfo=None) 

80 elapsed = timeutils.delta_seconds(last_heartbeat, timeutils.utcnow()) 

81 is_up = abs(elapsed) <= CONF.service_down_time 

82 if not is_up: 82 ↛ 91line 82 didn't jump to line 91 because the condition on line 82 was always true

83 LOG.warning('Seems service %(name)s on host %(host)s is down. ' 

84 'Last heartbeat was %(lhb)s.' 

85 'Elapsed time is %(el)s', 

86 {'name': service.name, 

87 'host': service.host, 

88 'lhb': str(last_heartbeat), 'el': str(elapsed)}) 

89 self._status = objects.service.ServiceStatus.FAILED 

90 else: 

91 self._status = objects.service.ServiceStatus.ACTIVE 

92 

93 id = wtypes.wsattr(int, readonly=True) 

94 """ID for this service.""" 

95 

96 name = wtypes.text 

97 """Name of the service.""" 

98 

99 host = wtypes.text 

100 """Host where service is placed on.""" 

101 

102 last_seen_up = wtypes.wsattr(datetime.datetime, readonly=True) 

103 """Time when Watcher service sent latest heartbeat.""" 

104 

105 status = wtypes.wsproperty(wtypes.text, _get_status, _set_status, 

106 mandatory=True) 

107 

108 links = wtypes.wsattr([link.Link], readonly=True) 

109 """A list containing a self link.""" 

110 

111 def __init__(self, **kwargs): 

112 super(Service, self).__init__() 

113 

114 fields = list(objects.Service.fields) + ['status'] 

115 self.fields = [] 

116 for field in fields: 

117 self.fields.append(field) 

118 setattr(self, field, kwargs.get( 

119 field if field != 'status' else 'id', wtypes.Unset)) 

120 

121 @staticmethod 

122 def _convert_with_links(service, url, expand=True): 

123 if not expand: 

124 service.unset_fields_except( 

125 ['id', 'name', 'host', 'status']) 

126 

127 service.links = [ 

128 link.Link.make_link('self', url, 'services', str(service.id)), 

129 link.Link.make_link('bookmark', url, 'services', str(service.id), 

130 bookmark=True)] 

131 return service 

132 

133 @classmethod 

134 def convert_with_links(cls, service, expand=True): 

135 service = Service(**service.as_dict()) 

136 hide_fields_in_newer_versions(service) 

137 return cls._convert_with_links( 

138 service, pecan.request.host_url, expand) 

139 

140 @classmethod 

141 def sample(cls, expand=True): 

142 sample = cls(id=1, 

143 name='watcher-applier', 

144 host='Controller', 

145 last_seen_up=datetime.datetime(2016, 1, 1)) 

146 return cls._convert_with_links(sample, 'http://localhost:9322', expand) 

147 

148 

149class ServiceCollection(collection.Collection): 

150 """API representation of a collection of services.""" 

151 

152 services = [Service] 

153 """A list containing services objects""" 

154 

155 def __init__(self, **kwargs): 

156 super(ServiceCollection, self).__init__() 

157 self._type = 'services' 

158 

159 @staticmethod 

160 def convert_with_links(services, limit, url=None, expand=False, 

161 **kwargs): 

162 service_collection = ServiceCollection() 

163 service_collection.services = [ 

164 Service.convert_with_links(g, expand) for g in services] 

165 service_collection.next = service_collection.get_next( 

166 limit, url=url, marker_field='id', **kwargs) 

167 return service_collection 

168 

169 @classmethod 

170 def sample(cls): 

171 sample = cls() 

172 sample.services = [Service.sample(expand=False)] 

173 return sample 

174 

175 

176class ServicesController(rest.RestController): 

177 """REST controller for Services.""" 

178 

179 def __init__(self): 

180 super(ServicesController, self).__init__() 

181 

182 from_services = False 

183 """A flag to indicate if the requests to this controller are coming 

184 from the top-level resource Services.""" 

185 

186 _custom_actions = { 

187 'detail': ['GET'], 

188 } 

189 

190 def _get_services_collection(self, marker, limit, sort_key, sort_dir, 

191 expand=False, resource_url=None): 

192 api_utils.validate_sort_key( 

193 sort_key, list(objects.Service.fields)) 

194 limit = api_utils.validate_limit(limit) 

195 api_utils.validate_sort_dir(sort_dir) 

196 

197 marker_obj = None 

198 if marker: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 marker_obj = objects.Service.get( 

200 pecan.request.context, marker) 

201 

202 sort_db_key = (sort_key if sort_key in objects.Service.fields 

203 else None) 

204 

205 services = objects.Service.list( 

206 pecan.request.context, limit, marker_obj, 

207 sort_key=sort_db_key, sort_dir=sort_dir) 

208 

209 return ServiceCollection.convert_with_links( 

210 services, limit, url=resource_url, expand=expand, 

211 sort_key=sort_key, sort_dir=sort_dir) 

212 

213 @wsme_pecan.wsexpose(ServiceCollection, int, int, wtypes.text, wtypes.text) 

214 def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc'): 

215 """Retrieve a list of services. 

216 

217 :param marker: pagination marker for large data sets. 

218 :param limit: maximum number of resources to return in a single result. 

219 :param sort_key: column to sort results by. Default: id. 

220 :param sort_dir: direction to sort. "asc" or "desc". Default: asc. 

221 """ 

222 context = pecan.request.context 

223 policy.enforce(context, 'service:get_all', 

224 action='service:get_all') 

225 

226 return self._get_services_collection(marker, limit, sort_key, sort_dir) 

227 

228 @wsme_pecan.wsexpose(ServiceCollection, int, int, wtypes.text, wtypes.text) 

229 def detail(self, marker=None, limit=None, sort_key='id', sort_dir='asc'): 

230 """Retrieve a list of services with detail. 

231 

232 :param marker: pagination marker for large data sets. 

233 :param limit: maximum number of resources to return in a single result. 

234 :param sort_key: column to sort results by. Default: id. 

235 :param sort_dir: direction to sort. "asc" or "desc". Default: asc. 

236 """ 

237 context = pecan.request.context 

238 policy.enforce(context, 'service:detail', 

239 action='service:detail') 

240 # NOTE(lucasagomes): /detail should only work against collections 

241 parent = pecan.request.path.split('/')[:-1][-1] 

242 if parent != "services": 

243 raise exception.HTTPNotFound 

244 expand = True 

245 resource_url = '/'.join(['services', 'detail']) 

246 

247 return self._get_services_collection( 

248 marker, limit, sort_key, sort_dir, expand, resource_url) 

249 

250 @wsme_pecan.wsexpose(Service, wtypes.text) 

251 def get_one(self, service): 

252 """Retrieve information about the given service. 

253 

254 :param service: ID or name of the service. 

255 """ 

256 if self.from_services: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

257 raise exception.OperationNotPermitted 

258 

259 context = pecan.request.context 

260 rpc_service = api_utils.get_resource('Service', service) 

261 policy.enforce(context, 'service:get', rpc_service, 

262 action='service:get') 

263 

264 return Service.convert_with_links(rpc_service)