• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SpiNNakerManchester / JavaSpiNNaker / 15206883803

23 May 2025 09:20AM UTC coverage: 37.541% (-0.8%) from 38.295%
15206883803

push

github

rowleya
Merge branch 'master' into doc_fix

114 of 152 new or added lines in 10 files covered. (75.0%)

231 existing lines in 4 files now uncovered.

9065 of 24147 relevant lines covered (37.54%)

1.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

1.54
/SpiNNaker-allocserv/src/main/java/uk/ac/manchester/spinnaker/alloc/web/SpallocServiceImpl.java
1
/*
2
 * Copyright (c) 2021 The University of Manchester
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
 *     https://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 implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package uk.ac.manchester.spinnaker.alloc.web;
17

18
import static java.lang.Math.max;
19
import static java.lang.String.format;
20
import static java.lang.String.join;
21
import static java.util.Objects.isNull;
22
import static java.util.Objects.nonNull;
23
import static java.util.stream.Collectors.toList;
24
import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
25
import static javax.ws.rs.core.Response.created;
26
import static javax.ws.rs.core.Response.ok;
27
import static javax.ws.rs.core.Response.status;
28
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
29
import static org.slf4j.LoggerFactory.getLogger;
30
import static uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateBoard.address;
31
import static uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateBoard.physical;
32
import static uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateBoard.triad;
33
import static uk.ac.manchester.spinnaker.alloc.web.WebServiceComponentNames.SERV;
34

35
import java.net.URI;
36
import java.util.ArrayList;
37
import java.util.HashMap;
38

39
import javax.servlet.http.HttpServletRequest;
40
import javax.ws.rs.Path;
41
import javax.ws.rs.container.AsyncResponse;
42
import javax.ws.rs.core.Response;
43
import javax.ws.rs.core.SecurityContext;
44
import javax.ws.rs.core.UriInfo;
45

46
import org.slf4j.Logger;
47
import org.springframework.beans.factory.ObjectProvider;
48
import org.springframework.beans.factory.annotation.Autowired;
49
import org.springframework.security.web.csrf.CsrfToken;
50
import org.springframework.stereotype.Service;
51

52
import com.fasterxml.jackson.core.JsonProcessingException;
53
import com.fasterxml.jackson.databind.json.JsonMapper;
54

55
import uk.ac.manchester.spinnaker.alloc.ServiceVersion;
56
import uk.ac.manchester.spinnaker.alloc.SpallocProperties;
57
import uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI;
58
import uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateDescriptor;
59
import uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateDimensions;
60
import uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateDimensionsAt;
61
import uk.ac.manchester.spinnaker.alloc.allocator.SpallocAPI.CreateNumBoards;
62
import uk.ac.manchester.spinnaker.alloc.security.Permit;
63
import uk.ac.manchester.spinnaker.alloc.web.RequestFailedException.BadArgs;
64
import uk.ac.manchester.spinnaker.alloc.web.RequestFailedException.NotFound;
65

66
/**
67
 * The implementation of the user-facing REST API. Operations are delegated to
68
 * {@link SpallocAPI} for fulfilment; this class is responsible for turning the
69
 * operations described by users into the form understood by the service core,
70
 * and for converting the responses. It also handles the transfer of calls onto
71
 * suitable worker threads, where appropriate.
72
 *
73
 * @author Donal Fellows
74
 */
75
@Service("service")
76
@Path(SERV)
77
public class SpallocServiceImpl extends BackgroundSupport
3✔
78
                implements SpallocServiceAPI {
79
        private static final Logger log = getLogger(SpallocServiceImpl.class);
3✔
80

81
        @Autowired
82
        private ServiceVersion version;
83

84
        @Autowired
85
        private SpallocProperties properties;
86

87
        @Autowired
88
        private SpallocAPI core;
89

90
        @Autowired
91
        private JsonMapper mapper;
92

93
        /**
94
         * Factory for {@linkplain MachineAPI machines}. Only use via
95
         * {@link #getMachine(String, UriInfo, SecurityContext) getMachine(...)};
96
         * this is because we're dealing with prototype beans.
97
         */
98
        @Autowired
99
        private ObjectProvider<MachineAPI> machineFactory;
100

101
        /**
102
         * Factory for {@linkplain JobAPI jobs}. Only use via
103
         * {@link #getJob(int, UriInfo, HttpServletRequest, SecurityContext)
104
         * getJob(...)}; this is because we're dealing with prototype beans.
105
         */
106
        @Autowired
107
        private ObjectProvider<JobAPI> jobFactory;
108

109
        @Override
110
        public ServiceDescription describeService(UriInfo ui, SecurityContext sec,
111
                        HttpServletRequest req) {
112
                var token = (CsrfToken) req.getAttribute("_csrf");
×
113
                return new ServiceDescription(version.getVersion(), ui, sec, token);
×
114
        }
115

116
        @Override
117
        public MachinesResponse getMachines(UriInfo ui) {
118
                return new MachinesResponse(core.getMachines(false), ui);
×
119
        }
120

121
        @Override
122
        public MachineAPI getMachine(String name, UriInfo ui, SecurityContext sec) {
123
                var permit = new Permit(sec);
×
124
                var machine = core.getMachine(name, permit.admin)
×
125
                                .orElseThrow(() -> new NotFound("no such machine"));
×
126
                // Wrap so we can use security annotations
127
                return machineFactory.getObject(machine, ui);
×
128
        }
129

130
        @Override
131
        public JobAPI getJob(int id, UriInfo ui, HttpServletRequest req,
132
                        SecurityContext security) {
133
                var permit = new Permit(security);
×
134
                var j = core.getJob(permit, id)
×
135
                                .orElseThrow(() -> new NotFound("no such job"));
×
136
                // Wrap so we can use security annotations
137
                return jobFactory.getObject(j, req.getRemoteHost(), permit, ui);
×
138
        }
139

140
        // Could be configurable, but no real point
141
        private static final int LIMIT_LIMIT = 200;
142

143
        /**
144
         * Adds in the {@code Link:} header with general paging info.
145
         *
146
         * @param value
147
         *            The core response.
148
         * @param ui
149
         *            Information about URIs
150
         * @param start
151
         *            The start offset.
152
         * @param limit
153
         *            The size of chunk.
154
         * @return Annotated response.
155
         */
156
        private Response wrapPaging(ListJobsResponse value, UriInfo ui, int start,
157
                        int limit) {
158
                var r = ok(value);
×
159
                var links = new HashMap<String, URI>();
×
160
                if (start > 0) {
×
161
                        var prev = ui.getRequestUriBuilder()
×
162
                                        .replaceQueryParam("wait", false)
×
163
                                        .replaceQueryParam("start", max(start - limit, 0)).build();
×
164
                        value.setPrev(prev);
×
165
                        links.put("prev", prev);
×
166
                }
167
                if (value.jobs.size() == limit) {
×
168
                        var next = ui.getRequestUriBuilder()
×
169
                                        .replaceQueryParam("wait", false)
×
170
                                        .replaceQueryParam("start", start + limit).build();
×
171
                        value.setNext(next);
×
172
                        links.put("next", next);
×
173
                }
174
                if (!links.isEmpty()) {
×
175
                        r.header("Link", join(", ", links.entrySet().stream().map(
×
176
                                        e -> format("<%s>; rel=\"%s\"", e.getValue(), e.getKey()))
×
177
                                        .collect(toList())));
×
178
                }
179
                return r.build();
×
180
        }
181

182
        @Override
183
        public void listJobs(boolean wait, boolean destroyed, int limit, int start,
184
                        UriInfo ui, AsyncResponse response) {
185
                if (limit > LIMIT_LIMIT || limit < 1) {
×
186
                        throw new BadArgs("limit must not be bigger than " + LIMIT_LIMIT);
×
187
                }
188
                if (start < 0) {
×
189
                        throw new BadArgs("start must not be less than 0");
×
190
                }
191
                var jc = core.getJobs(destroyed, limit, start);
×
192
                if (wait) {
×
193
                        bgAction(response, () -> {
×
194
                                log.debug("starting wait for change of job list");
×
195
                                jc.waitForChange(properties.getWait());
×
196
                                var newJc = core.getJobs(destroyed, limit, start);
×
197
                                return wrapPaging(new ListJobsResponse(newJc, ui), ui, start,
×
198
                                                limit);
199
                        });
200
                } else {
201
                        fgAction(response, () -> wrapPaging(new ListJobsResponse(jc, ui),
×
202
                                        ui, start, limit));
203
                }
204
        }
×
205

206
        private static String trim(String str) {
207
                if (isNull(str)) {
×
208
                        return null;
×
209
                }
210
                return str.strip();
×
211
        }
212

213
        /**
214
         * Select one of the ways to create a job based on the request parameters.
215
         * Note this will pick the first non-null of (group, nmpiCollabId,
216
         * nmpiJobId) from req to determine the spalloc call to make (also
217
         * acceptable if all are null).
218
         *
219
         * @param req
220
         *            The request details.
221
         * @param crds
222
         *            The request credentials.
223
         * @return The job created.
224
         * @throws JsonProcessingException
225
         *             If there is an error converting the request into bytes.
226
         */
227
        private SpallocAPI.Job createJob(CreateJobRequest req,
228
                        CreateDescriptor crds) throws JsonProcessingException {
229
                if (!isNull(req.group)) {
×
230
                        return core.createJobInGroup(trim(req.owner), trim(req.group), crds,
×
231
                                        req.machineName, req.tags, req.keepaliveInterval,
232
                                        mapper.writeValueAsBytes(req));
×
233
                }
234
                if (!isNull(req.nmpiCollab)) {
×
235
                        return core.createJobInCollabSession(trim(req.owner),
×
236
                                        trim(req.nmpiCollab), crds,
×
237
                                        req.machineName, req.tags, req.keepaliveInterval,
238
                                        mapper.writeValueAsBytes(req));
×
239
                }
240
                if (!isNull(req.nmpiJobId)) {
×
241
                        return core.createJobForNMPIJob(trim(req.owner),
×
242
                                        req.nmpiJobId, crds,
×
243
                                        req.machineName, req.tags, req.keepaliveInterval,
244
                                        mapper.writeValueAsBytes(req));
×
245
                }
246
                return core.createJob(trim(req.owner), crds,
×
247
                                req.machineName, req.tags, req.keepaliveInterval,
248
                                mapper.writeValueAsBytes(req));
×
249
        }
250

251
        @Override
252
        public void createJob(CreateJobRequest req, UriInfo ui,
253
                        SecurityContext security, AsyncResponse response) {
254
                var crds = validateAndApplyDefaultsToJobRequest(req, security);
×
255

256
                // Ensure we only have at most one "group" specifier (0 also fine).
257
                var nonNullGroups = 0;
×
258
                var items = new Object[] {
×
259
                        req.group, req.nmpiCollab, req.nmpiJobId
260
                };
261
                for (Object item : items) {
×
262
                        if (!isNull(item)) {
×
263
                                nonNullGroups += 1;
×
264
                        }
265
                }
266
                if (nonNullGroups > 1) {
×
267
                        response.resume(status(BAD_REQUEST).type(TEXT_PLAIN).entity(
×
268
                                        "At most one of group, nmpiCollabId or nmpiJobId"
269
                                        + " can be specified").build());
×
270
                }
271
                bgAction(response, () -> {
×
272
                        try {
273
                                var job = createJob(req, crds);
×
274
                                return created(ui.getRequestUriBuilder().path("{id}")
×
275
                                                .build(job.getId()))
×
276
                                                .entity(new CreateJobResponse(job, ui)).build();
×
277
                        } catch (IllegalArgumentException e) {
×
278
                                return status(BAD_REQUEST).type(TEXT_PLAIN)
×
279
                                                .entity(e.getMessage()).build();
×
280
                        }
281
                });
282
        }
×
283

284
        private CreateDescriptor validateAndApplyDefaultsToJobRequest(
285
                        CreateJobRequest req, SecurityContext security) throws BadArgs {
286
                if (isNull(req)) {
×
287
                        throw new BadArgs("request must be supplied");
×
288
                }
289

290
                if (!security.isUserInRole("ADMIN")
×
291
                                && !security.isUserInRole("NMPI_EXEC")
×
292
                                && !isNull(req.owner) && !req.owner.isBlank()) {
×
293
                        throw new BadArgs("Only admin and NMPI users can specify an owner");
×
294
                }
295

296
                if (isNull(req.owner) || req.owner.isBlank()) {
×
297
                        req.owner = security.getUserPrincipal().getName();
×
298
                }
299
                if (isNull(req.owner) || req.owner.isBlank()) {
×
300
                        throw new BadArgs(
×
301
                                        "request must be connected to an identified owner");
302
                }
303
                req.owner = req.owner.strip();
×
304

305
                var ka = properties.getKeepalive();
×
306
                if (isNull(req.keepaliveInterval)
×
307
                                || req.keepaliveInterval.compareTo(ka.getMin()) < 0) {
×
308
                        throw new BadArgs(
×
309
                                        "keepalive interval must be at least " + ka.getMin());
×
310
                }
311
                if (req.keepaliveInterval.compareTo(ka.getMax()) > 0) {
×
312
                        throw new BadArgs(
×
313
                                        "keepalive interval must be no more than " + ka.getMax());
×
314
                }
315

316
                if (isNull(req.tags)) {
×
317
                        req.tags = new ArrayList<>();
×
318
                        if (isNull(req.machineName)) {
×
319
                                req.tags.add("default");
×
320
                        }
321
                }
322
                if (nonNull(req.machineName) && !req.tags.isEmpty()) {
×
323
                        throw new BadArgs(
×
324
                                        "must not specify machine name and tags together");
325
                }
326

327
                if (isNull(req.maxDeadBoards)) {
×
328
                        req.maxDeadBoards = 0;
×
329
                } else if (req.maxDeadBoards < 0) {
×
330
                        throw new BadArgs(
×
331
                                        "the maximum number of dead boards must not be negative");
332
                }
333

334
                if (nonNull(req.numBoards)) {
×
335
                        return new CreateNumBoards(req.numBoards, req.maxDeadBoards);
×
336
                } else if (nonNull(req.dimensions)) {
×
337
                        if (nonNull(req.board)) {
×
338
                                // Both dimensions AND board; rooted rectangle
339
                                if (nonNull(req.board.x)) {
×
340
                                        return new CreateDimensionsAt(req.dimensions.width,
×
341
                                                        req.dimensions.height, req.board.x, req.board.y,
342
                                                        req.board.z, req.maxDeadBoards);
343
                                } else if (nonNull(req.board.cabinet)) {
×
344
                                        return CreateDimensionsAt.physical(req.dimensions.width,
×
345
                                                        req.dimensions.height, req.board.cabinet,
346
                                                        req.board.frame, req.board.board,
347
                                                        req.maxDeadBoards);
348
                                } else {
349
                                        return new CreateDimensionsAt(req.dimensions.width,
×
350
                                                        req.dimensions.height, req.board.address,
351
                                                        req.maxDeadBoards);
352
                                }
353
                        }
354
                        return new CreateDimensions(req.dimensions.width,
×
355
                                        req.dimensions.height, req.maxDeadBoards);
356
                } else if (nonNull(req.board)) {
×
357
                        if (nonNull(req.board.x)) {
×
358
                                return triad(req.board.x, req.board.y, req.board.z);
×
359
                        } else if (nonNull(req.board.cabinet)) {
×
360
                                return physical(req.board.cabinet, req.board.frame,
×
361
                                                req.board.board);
×
362
                        } else {
363
                                return address(req.board.address);
×
364
                        }
365
                } else {
366
                        // It's a single board
367
                        return new CreateNumBoards(1, 0);
×
368
                }
369
        }
370

371
        @Override
372
        public void emergencyStop(String commandCode, AsyncResponse response) {
NEW
373
                bgAction(response, () -> {
×
NEW
374
                        core.emergencyStop(commandCode);
×
NEW
375
                        return ok().build();
×
376
                });
NEW
377
        }
×
378
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc