package com.danielflower.apprunner.web.v1; import com.danielflower.apprunner.AppEstate; import com.danielflower.apprunner.FileSandbox; import com.danielflower.apprunner.io.OutputToWriterBridge; import com.danielflower.apprunner.io.Zippy; import com.danielflower.apprunner.mgmt.*; import com.danielflower.apprunner.problems.AppNotFoundException; import com.danielflower.apprunner.runners.UnsupportedProjectTypeException; import io.muserver.rest.ApiResponse; import io.muserver.rest.Description; import io.muserver.rest.Required; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.StringBuilderWriter; import org.eclipse.jetty.io.WriterOutputStream; import org.eclipse.jgit.api.errors.GitAPIException; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.ws.rs.*; import javax.ws.rs.core.*; import java.io.*; import java.net.URI; import java.util.*; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import static org.apache.commons.io.IOUtils.LINE_SEPARATOR; import static org.apache.commons.lang3.StringUtils.isBlank; @Description(value = "Application") @Path("/apps") public class AppResource { public static final Logger log = LoggerFactory.getLogger(AppResource.class); private final AppEstate estate; private final SystemInfo systemInfo; private final FileSandbox fileSandbox; public AppResource(AppEstate estate, SystemInfo systemInfo, FileSandbox fileSandbox) { this.estate = estate; this.systemInfo = systemInfo; this.fileSandbox = fileSandbox; } @GET @Produces(MediaType.APPLICATION_JSON) @Description(value = "Gets all registered apps") public String apps(@Context UriInfo uriInfo) { JSONObject result = new JSONObject(); List<JSONObject> apps = new ArrayList<>(); estate.all() .sorted(Comparator.comparing(AppDescription::name)) .forEach(d -> apps.add( appJson(uriInfo.getRequestUri(), d))); result.put("appCount", apps.size()); result.put("apps", apps); return result.toString(4); } @GET @Path("/{name}") @Produces(MediaType.APPLICATION_JSON) @Description(value = "Gets a single app") public Response app(@Context UriInfo uriInfo, @Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) { Optional<AppDescription> app = estate.app(name); if (app.isPresent()) { return Response.ok(appJson(uriInfo.getRequestUri(), app.get()).toString(4)).type(MediaType.APPLICATION_JSON).build(); } else { return Response.status(Response.Status.NOT_FOUND).build(); } } @GET @Produces("text/plain;charset=utf-8") @Path("/{name}/build.log") @Description(value = "Gets the latest build log as plain text for the given app") public String buildLogs(@Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) { Optional<AppDescription> namedApp = estate.app(name); if (namedApp.isPresent()) return namedApp.get().latestBuildLog(); throw new AppNotFoundException("No app found with name '" + name + "'. Valid names: " + estate.allAppNames()); } @GET @Produces("text/plain;charset=utf-8") @Path("/{name}/console.log") @Description(value = "Gets the latest console log as plain text for the given app") public String consoleLogs(@Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) { Optional<AppDescription> namedApp = estate.app(name); if (namedApp.isPresent()) return namedApp.get().latestConsoleLog(); throw new AppNotFoundException("No app found with name '" + name + "'. Valid names: " + estate.allAppNames()); } @GET @Produces("application/zip") @Path("/{name}/data") @Description(value = "Gets the contents of the app's data directory as a zip file") public StreamingOutput getAppData(@Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) { AppDescription ad = getAppDescription(name); log.info("Getting data for " + name); return output -> Zippy.zipDirectory(ad.dataDir(), output); } private AppDescription getAppDescription(@Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) { Optional<AppDescription> namedApp = estate.app(name); if (!namedApp.isPresent()) throw new NotFoundException("No app with name " + name); return namedApp.get(); } @DELETE @Path("/{name}/data") @Description(value = "Deletes all the files for an app") @ApiResponse(code = "204", message = "Files deleted successfully") @ApiResponse(code = "500", message = "At least one file could not be deleted") public Response deleteAppData(@Required @Description(value="The name of the app") @PathParam("name") String name) throws IOException { AppDescription ad = getAppDescription(name); File[] children = ad.dataDir().listFiles(); if (children == null) { return Response.serverError().entity("Could not access data dir").build(); } for (File child : children) { log.info("Deleting " + child.getCanonicalPath()); if (child.isFile()) { if (!child.delete()) { return Response.serverError().entity("Could not delete " + child.getName()).build(); } } else { FileUtils.deleteDirectory(child); } } return Response.noContent().build(); } @POST @Consumes({"application/octet-stream", "application/zip"}) @Path("/{name}/data") @Description(value = "Sets the contents of the app's data directory with the contents of the zip file") @ApiResponse(code = "204", message = "Files uploaded successfully") public Response setAppData(@Required @Description(value = "The name of the app", example = "app-runner-home") @PathParam("name") String name, @Description("A zip file containing files that will be unzipped") @Required InputStream requestBody) throws IOException { AppDescription ad = getAppDescription(name); if (ad.dataDir().listFiles().length > 0) { return Response.status(400).entity("File uploading is only supported for apps with empty data directories.").build(); } log.info("Setting data for " + name); String dataDirPath = ad.dataDir().getCanonicalPath(); File unzipTo = fileSandbox.tempDir("post-data-" + UUID.randomUUID().toString()); String unzipToPath = unzipTo.getCanonicalPath(); log.info("Going to unzip files to temp dir " + unzipToPath); int filesUnzipped = 0; try (ZipInputStream zis = new ZipInputStream(requestBody)) { ZipEntry nextEntry; while ((nextEntry = zis.getNextEntry()) != null) { filesUnzipped++; String destPath = FilenameUtils.concat(unzipToPath, nextEntry.getName()); File dest = new File(destPath); if (nextEntry.isDirectory()) { if (dest.mkdirs()) { log.info("Created " + destPath); } else { log.warn("Failed to create " + destPath); } } else { if (dest.getParentFile().mkdirs()) { log.info("Created " + dest.getParentFile()); } log.info("Unzipping " + (nextEntry.getName())); try (FileOutputStream fos = new FileOutputStream(dest, false)) { IOUtils.copy(zis, fos); } } } } if (filesUnzipped > 0) { log.info("Going to move temp dir to app data path " + ad.dataDir().getCanonicalPath()); if (!ad.dataDir().delete()) { log.warn("Could not delete old data dir"); } FileUtils.moveDirectory(unzipTo, new File(dataDirPath)); } return Response.status(204).build(); } private JSONObject appJson(URI uri, AppDescription app) { URI restURI = uri.resolve("/api/v1/"); Availability availability = app.currentAvailability(); BuildStatus lastBuildStatus = app.lastBuildStatus(); BuildStatus lastSuccessfulBuild = app.lastSuccessfulBuild(); return new JSONObject() .put("name", app.name()) .put("contributors", getContributorsList(app)) .put("buildLogUrl", appUrl(app, restURI, "build.log")) .put("consoleLogUrl", appUrl(app, restURI, "console.log")) .put("url", uri.resolve("/" + app.name() + "/")) .put("deployUrl", appUrl(app, restURI, "deploy")) .put("available", availability.isAvailable) .put("availableStatus", availability.availabilityStatus) .put("lastBuild", lastBuildStatus.toJSON()) .put("lastSuccessfulBuild", lastSuccessfulBuild == null ? null : lastSuccessfulBuild.toJSON()) .put("gitUrl", app.gitUrl()) .put("host", systemInfo.hostName); } private static String getContributorsList(AppDescription app) { String contributors = ""; String[] contributorsArray = app.contributors().toArray(new String[0]); Arrays.sort(contributorsArray); for (String name : contributorsArray) { contributors += name + ", "; } if (contributors.length() > 2) { contributors = contributors.substring(0, contributors.length() - 2); } return contributors; } private static URI appUrl(AppDescription app, URI restURI, String path) { return restURI.resolve("apps/" + app.name() + "/" + path); } @POST @Produces(MediaType.APPLICATION_JSON) @Consumes({MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON}) @Description(value = "Registers a new app with AppRunner. Note that it does not deploy it.") @ApiResponse(code = "201", message = "The new app was successfully registered") @ApiResponse(code = "400", message = "The git URL was not specified or the git repo could not be cloned, or the app name is not valid.") @ApiResponse(code = "409", message = "There is already an app with that name") @ApiResponse(code = "501", message = "The app type is not supported by this apprunner") public Response create(@Context UriInfo uriInfo, @Required @Description("An SSH or HTTP git URL that points to an app-runner compatible app") @FormParam("gitUrl") String gitUrl, @Description("The ID that the app will be referenced which should just be letters, numbers, and hyphens. Leave blank to infer it from the git URL") @FormParam("appName") String appName) { log.info("Received request to create " + gitUrl); if (isBlank(gitUrl)) { return Response.status(400).entity(new JSONObject() .put("message", "No git URL was specified") .toString()).build(); } appName = isBlank(appName) ? AppManager.nameFromUrl(gitUrl) : appName; Optional<AppDescription> existing = estate.app(appName); if (existing.isPresent()) { return Response.status(409).entity(new JSONObject() .put("message", "There is already an app with that ID") .toString()).build(); } return responseForAddingAppToEstate(uriInfo, gitUrl, appName, 201); } private Response responseForAddingAppToEstate(@Context UriInfo uriInfo, String gitUrl, String appName, int status) { AppDescription appDescription; try { appDescription = estate.addApp(gitUrl, appName); return Response.status(status) .header("Location", uriInfo.getRequestUri() + "/" + appDescription.name()) .header("Content-Type", "application/json") .entity(appJson(uriInfo.getRequestUri(), estate.app(appName).get()).toString(4)) .build(); } catch (UnsupportedProjectTypeException e) { return Response.status(501) .header("Content-Type", "application/json") .entity(new JSONObject() .put("message", "No suitable runner found for this app") .put("gitUrl", gitUrl) .toString(4)) .build(); } catch (GitAPIException e) { return Response.status(400) .header("Content-Type", "application/json") .entity(new JSONObject() .put("message", "Could not clone git repository: " + e.getMessage()) .put("gitUrl", gitUrl) .toString(4)) .build(); } catch (ValidationException ve) { return Response.status(400) .header("Content-Type", "application/json") .entity(new JSONObject() .put("message", ve.getMessage()) .toString(4)) .build(); } catch (Exception e) { String errorId = "ERR" + System.currentTimeMillis(); log.error("Error while adding app. ErrorID=" + errorId, e); return Response.serverError() .header("Content-Type", "application/json") .entity(new JSONObject() .put("message", "Error while adding app") .put("errorID", errorId) .put("detailedError", e.toString()) .put("gitUrl", gitUrl) .toString(4)) .build(); } } @PUT @Path("/{name}") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Description(value = "Updates the git URL of an existing app") @ApiResponse(code = "200", message = "Success - call deploy after this to build and deploy from the new URL") @ApiResponse(code = "400", message = "The name or git URL was not specified or the git repo could not be cloned") @ApiResponse(code = "404", message = "The app does not exist") @ApiResponse(code = "501", message = "The app type is not supported by this apprunner") public Response update(@Context UriInfo uriInfo, @Required @Description(value = "An SSH or HTTP git URL that points to an app-runner compatible app") @FormParam("gitUrl") String gitUrl, @Description(value = "The ID of the app to update") @PathParam("name") String appName) { log.info("Received request to update " + appName + " to " + gitUrl); if (isBlank(gitUrl) || isBlank(appName)) { return Response.status(400).entity(new JSONObject() .put("message", "No git URL was specified") .toString()).build(); } Optional<AppDescription> existing = estate.app(appName); if (!existing.isPresent()) { return Response.status(404).entity(new JSONObject() .put("message", "No application called " + appName + " exists") .toString()).build(); } try { estate.remove(existing.get()); return responseForAddingAppToEstate(uriInfo, gitUrl, appName, 200); } catch (Exception e) { String errorId = "ERR" + System.currentTimeMillis(); log.error("Error while updating app. ErrorID=" + errorId, e); return Response.serverError().entity(new JSONObject() .put("message", "Error while updating app") .put("errorID", errorId) .put("detailedError", e.toString()) .put("gitUrl", gitUrl) .toString(4)).build(); } } @DELETE @Produces(MediaType.APPLICATION_JSON) @Path("/{name}") @Description(value = "De-registers an application") public Response delete(@Context UriInfo uriInfo, @Description(value="The name of the app") @PathParam("name") String name) throws IOException { Optional<AppDescription> existing = estate.app(name); if (existing.isPresent()) { AppDescription appDescription = existing.get(); String entity = appJson(uriInfo.getRequestUri(), appDescription).toString(4); estate.remove(appDescription); return Response.ok(entity).build(); } else { return Response.status(400).entity("Could not find app with name " + name).build(); } } @POST /* Maybe should be PUT, but too many hooks only use POST */ @Produces({MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON}) @Path("/{name}/deploy") @Description(value = "Deploys an app", details = "Deploys the app by fetching the latest changes from git, building it, " + "starting it, polling for successful startup by making GET requests to /{name}/, and if it returns any HTTP response " + "it shuts down the old version of the app. If any steps before that fail, the old version of the app will continue serving " + "requests.") @ApiResponse(code = "200", message = "Returns 200 if the command was received successfully. Whether the build " + "actually succeeds or fails is ignored. Returns streamed plain text of the build log and console startup, unless the Accept" + " header includes 'application/json'.") public Response deploy(@Context UriInfo uriInfo, @Description(value = "The type of response desired, e.g. application/json or text/plain", example = "application/json") @HeaderParam("Accept") String accept, @Required @Description(value="The name of the app", example = "app-runner-home") @PathParam("name") String name) throws IOException { StreamingOutput stream = new UpdateStreamer(name); if (MediaType.APPLICATION_JSON.equals(accept)) { StringBuilderWriter output = new StringBuilderWriter(); try (WriterOutputStream writer = new WriterOutputStream(output)) { stream.write(writer); return app(uriInfo, name); } } else { return Response.ok(stream).type("text/plain;charset=utf-8").build(); } } private class UpdateStreamer implements StreamingOutput { private final String name; UpdateStreamer(String name) { this.name = name; } public void write(OutputStream output) throws IOException, WebApplicationException { try (Writer writer = new OutputStreamWriter(output)) { writer.write("Going to build and deploy " + name + " at " + new Date() + LINE_SEPARATOR); writer.flush(); log.info("Going to update " + name); try { estate.update(name, new OutputToWriterBridge(writer)); log.info("Finished updating " + name); writer.write("Success" + LINE_SEPARATOR); } catch (AppNotFoundException e) { Response r = Response.status(404).entity(e.getMessage()).build(); throw new WebApplicationException(r); } catch (Exception e) { log.error("Error while updating " + name, e); writer.write("Error while updating: " + e); if (e instanceof IOException) { throw (IOException) e; } } } } } @PUT @Path("/{name}/stop") @Description(value = "Stop an app from running, but does not de-register it. Call the deploy action to restart it.") public Response stop(@Description(value = "The app to stop") @PathParam("name") String name) { Optional<AppDescription> app = estate.app(name); if (app.isPresent()) { try { log.info("Going to stop " + name); app.get().stopApp(); return Response.ok().build(); } catch (Exception e) { log.error("Couldn't stop app via REST call", e); return Response.serverError().entity(e.toString()).build(); } } else { return Response.status(404).build(); } } }