Skip to content

Commit 614c5b1

Browse files
authored
feat: add interface to expose HTTP ports on batch jobs (#52)
1 parent 115f14e commit 614c5b1

File tree

17 files changed

+834
-44
lines changed

17 files changed

+834
-44
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
### Added
88

9-
* The `JuliaHub.authenticate` function now supports a two-argument form, where you can pass the JuliaHub token in directly, bypassing interactive authentication. (??)
9+
* The `JuliaHub.authenticate` function now supports a two-argument form, where you can pass the JuliaHub token in directly, bypassing interactive authentication. (#58)
10+
* The `JuliaHub.submit_job` function now allows submitting jobs that expose ports (via the `expose` argument). Related to that, the new `JuliaHub.request` function offers a simple interface for constructing authenticated HTTP.jl requests against the job, and the domain name of the job can be accessed via the new `.hostname` property of the `Job` object. (#14, #52)
1011

1112
## Version v0.1.10 - 2024-05-31
1213

docs/src/guides/jobs.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,60 @@ To actually fetch the contents of a file, you can use the [`download_job_file`](
230230
```@setup job-outputs
231231
empty!(Main.MOCK_JULIAHUB_STATE)
232232
```
233+
234+
## [Opening ports on batch jobs](@id jobs-batch-expose-port)
235+
236+
```@setup job-expose-port
237+
Main.MOCK_JULIAHUB_STATE[:jobs] = Dict(
238+
"jr-xf4tslavut" => Dict(
239+
"proxy_link" => "https://afyux.launch.juliahub.app/"
240+
)
241+
)
242+
import JuliaHub
243+
job = JuliaHub.job("jr-xf4tslavut")
244+
```
245+
246+
If supported for a given product and user, you can expose a single port on the job serving a HTTP server, to do HTTP requests to the job from the outside.
247+
This could be used to run "interactive" jobs that respond to user inputs, or to poll the job for data.
248+
249+
For example, the following job would run a simple [Oxygen.jl-based server](https://juliahub.com/ui/Packages/General/Oxygen) that exposes a simple API at the `/` path.
250+
251+
```@example job-expose-port
252+
import JuliaHub # hide
253+
job = JuliaHub.submit_job(
254+
JuliaHub.script"""
255+
using Oxygen, HTTP
256+
PORT = parse(Int, ENV["PORT"])
257+
@get "/" function(req::HTTP.Request)
258+
return "success"
259+
end
260+
serve(; host="0.0.0.0", port = PORT)
261+
""",
262+
expose = 8080,
263+
)
264+
```
265+
266+
Note that, unlike a usual batch job, this job has a `.hostname` property, that will point to the DNS hostname that can be used to access the server exposed by the job (see also [the relevant reference section](@ref jobs-apis-expose-ports)).
267+
268+
Once the job has started and the Oxygen-based server has started serving the page, you can perform [HTTP.jl](https://juliahub.com/ui/Packages/General/HTTP) requests against the job with the [`JuliaHub.request`](@ref) function, which is thin wrapper around the `HTTP.request` function that sets up the necessary authentication headers and constructs the full URL.
269+
270+
```@repl job-expose-port
271+
JuliaHub.request(job, "GET", "/")
272+
```
273+
274+
!!! note "502 Bad Gateway"
275+
276+
When the job is starting up or if the HTTP server in the job is not running, you can expect a `502 Bad Gateway` HTTP response from the job domain.
277+
278+
!!! tip "HTML page"
279+
280+
If the server can serve a HTML page, then you can also access the job in the browser.
281+
The web UI will also have a "Connect" link, like for other interactive applications.
282+
283+
!!! note "Pricing"
284+
285+
Jobs that expose ports may be priced differently per hour than batch jobs that do not open ports.
286+
287+
```@setup job-expose-port
288+
empty!(Main.MOCK_JULIAHUB_STATE)
289+
```

docs/src/reference/jobs.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ classDef user fill:lightgray
4444
job = JuliaHub.job(job)
4545
```
4646

47+
## [Jobs with exposed ports](@id jobs-apis-expose-ports)
48+
49+
Some JuliaHub jobs may expose ports and can be communicated with from the outside over the network (e.g. [batch jobs that expose ports](@ref jobs-batch-expose-port)).
50+
51+
If the job exposes a port, it can be accessed at a dedicated hostname (see the `.hostname` property of the [`Job`](@ref) object).
52+
The server running on the job is always exposed on port `443` on the public hostname, and the communication is TLS-wrapped (i.e. you need to connect to it over the HTTPS protocol).
53+
In most cases, your requests to the job also need to be authenticated (see also the [`JuliaHub.request`](@ref) function).
54+
55+
See also: [the guide on submitting batch jobs with open ports](@ref jobs-batch-expose-port), [`expose` argument for `JuliaHub.submit_job`](@ref JuliaHub.submit_job), [`JuliaHub.request`](@ref)
56+
4757
## Reference
4858

4959
```@docs
@@ -70,6 +80,7 @@ JuliaHub.Job
7080
JuliaHub.JobStatus
7181
JuliaHub.JobFile
7282
JuliaHub.FileHash
83+
JuliaHub.request
7384
```
7485

7586
## Index

src/JuliaHub.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ include("node.jl")
2626
include("jobsubmission.jl")
2727
include("PackageBundler/PackageBundler.jl")
2828
include("jobs/jobs.jl")
29+
include("jobs/request.jl")
2930
include("jobs/logging.jl")
3031
include("jobs/logging-kafka.jl")
3132
include("jobs/logging-legacy.jl")

src/authentication.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,10 @@ function reauthenticate!(
481481
auth._tokenpath = new_auth._tokenpath
482482
return auth
483483
end
484+
485+
# This can be interpolated into the docstrings of functions that take the
486+
# auth::Authentication = __auth__() keyword argument.
487+
const _DOCS_authentication_kwarg = """
488+
* `auth :: Authentication`: optional authentication object (see
489+
[the authentication section](@ref authentication) for more information)
490+
"""

src/batchimages.jl

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Base.@kwdef struct BatchImage
1818
_cpu_image_key::Union{String, Nothing}
1919
_gpu_image_key::Union{String, Nothing}
2020
_is_product_default::Bool
21+
_interactive_product_name::Union{String, Nothing}
2122
end
2223

2324
function Base.show(io::IO, image::BatchImage)
@@ -33,6 +34,10 @@ function Base.show(io::IO, ::MIME"text/plain", image::BatchImage)
3334
print(io, '\n', " image: ", image.image)
3435
isnothing(image._cpu_image_key) || print(io, "\n CPU image: ", image._cpu_image_key)
3536
isnothing(image._gpu_image_key) || print(io, "\n GPU image: ", image._gpu_image_key)
37+
if !isnothing(image._interactive_product_name)
38+
print(io, "\n Features:")
39+
print(io, "\n - Expose Port: ✓")
40+
end
3641
end
3742

3843
# This value is used in BatchImages objects when running against older JuliaHub
@@ -283,21 +288,51 @@ function _is_batch_app(app::DefaultApp)
283288
compute_type in ("batch", "singlenode-batch") && (input_type == "userinput")
284289
end
285290

291+
function _is_interactive_batch_app(app::DefaultApp)
292+
# Like _is_batch_app, this should return false for JuliaHub <= 6.1
293+
compute_type = get(app._json, "compute_type_name", nothing)
294+
input_type = get(app._json, "input_type_name", nothing)
295+
compute_type in ("distributed-interactive",) && (input_type == "userinput")
296+
end
297+
286298
function _batchimages_62(auth::Authentication)
287299
image_groups = _product_image_groups(auth)
288-
batchapps = filter(_is_batch_app, _apps_default(auth))
300+
batchapps, interactiveapps = let apps = _apps_default(auth)
301+
filter(_is_batch_app, apps), filter(_is_interactive_batch_app, apps)
302+
end
289303
batchimages = map(batchapps) do app
290304
product_name = app._json["product_name"]
291305
image_group = app._json["image_group"]
292306
images = get(image_groups, image_group, [])
293307
if isempty(images)
294308
@warn "Invalid image_group '$image_group' for '$product_name'" app
295309
end
310+
matching_interactive_app = filter(interactiveapps) do app
311+
get(app._json, "image_group", nothing) == image_group
312+
end
313+
interactive_product_name = if length(matching_interactive_app) > 1
314+
# If there are multiple interactive products configured for a batch product
315+
# we issue a warning and disable the 'interactive' compute for it (i.e. the user
316+
# won't be able to start jobs that require a port to be exposed until the configuration
317+
# issue is resolved).
318+
@warn "Multiple matching interactive apps for $(app)" image_group matches =
319+
matching_interactive_app
320+
nothing
321+
elseif isempty(matching_interactive_app)
322+
# If we can't find a matching 'distributed-interactive' product, we disable the
323+
# ability for the user to expose a port with this image.
324+
nothing
325+
else
326+
only(matching_interactive_app)._json["product_name"]
327+
end
296328
map(images) do (display_name, imagekey)
297329
BatchImage(;
298-
product=product_name, image=display_name,
299-
_cpu_image_key=imagekey.cpu, _gpu_image_key=imagekey.gpu,
300-
_is_product_default=imagekey.isdefault,
330+
product = product_name,
331+
image = display_name,
332+
_cpu_image_key = imagekey.cpu,
333+
_gpu_image_key = imagekey.gpu,
334+
_is_product_default = imagekey.isdefault,
335+
_interactive_product_name = interactive_product_name,
301336
)
302337
end
303338
end

src/jobs/jobs.jl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ Represents a single job submitted to JuliaHub. Objects have the following proper
199199
explicitly set)
200200
* `files :: Vector{JobFiles}`: a list of [`JobFile`](@ref) objects, representing the input and
201201
output files of the job (see: [`job_files`](@ref), [`job_file`](@ref), [`download_job_file`](@ref)).
202+
* `hostname :: Union{String, Nothing}`: for jobs that expose a port over HTTP, this will be set to the
203+
hostname of the job (`nothing` otherwise; see: [the relevant section in the manual](@ref jobs-batch-expose-port))
202204
203205
See also: [`job`](@ref), [`jobs`](@ref).
204206
@@ -213,6 +215,7 @@ struct Job
213215
env::Dict{String, Any}
214216
results::String
215217
files::Vector{JobFile}
218+
hostname::Union{String, Nothing}
216219
_timestamp_submit::Union{String, Nothing}
217220
_timestamp_start::Union{String, Nothing}
218221
_timestamp_end::Union{String, Nothing}
@@ -239,13 +242,36 @@ struct Job
239242
)
240243
end
241244
end
245+
hostname = let proxy_link = get(j, "proxy_link", "")
246+
if isempty(proxy_link)
247+
nothing
248+
else
249+
uri = URIs.URI(proxy_link)
250+
checks = (
251+
uri.scheme == "https",
252+
!isempty(uri.host),
253+
isempty(uri.path) || uri.path == "/",
254+
isempty(uri.query),
255+
isempty(uri.fragment),
256+
)
257+
if !all(checks)
258+
# Some jobs can have non-empty proxy links that are not proper hostnames.
259+
# We'll just ignore those for now.
260+
@debug "Unable to parse 'proxy_link' JSON for job '$jobname': '$(proxy_link)'"
261+
nothing
262+
else
263+
uri.host
264+
end
265+
end
266+
end
242267
return new(
243268
jobname,
244269
_get_json_or(j, "jobname_alias", Union{String, Nothing}, nothing),
245270
JobStatus(_json_get(j, "status", String; var)),
246271
inputs,
247272
outputs,
248273
haskey(j, "files") ? JobFile.(jobname, j["files"]; var) : JobFile[],
274+
hostname,
249275
# Under some circumstances, submittimestamp can also be nothing, even though that is
250276
# weird.
251277
_json_get(j, "submittimestamp", Union{String, Nothing}; var), # TODO: drop Nothing?
@@ -270,6 +296,7 @@ function Base.show(io::IO, ::MIME"text/plain", job::Job)
270296
print(io, '\n', " submitted: ", job._timestamp_submit)
271297
isnothing(job._timestamp_start) || print(io, '\n', " started: ", job._timestamp_start)
272298
isnothing(job._timestamp_end) || print(io, '\n', " finished: ", job._timestamp_end)
299+
isnothing(job.hostname) || print(io, '\n', " hostname: ", job.hostname)
273300
# List of job files:
274301
if !isempty(job.files)
275302
print(io, '\n', " files: ")

src/jobs/request.jl

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
function request(
3+
job::Job,
4+
method::AbstractString,
5+
uripath::AbstractString,
6+
[body];
7+
[auth::Authentication],
8+
[extra_headers],
9+
kwargs...
10+
) -> HTTP.Response
11+
12+
Performs an authenticated HTTP request against the HTTP server exposed by the job
13+
(with the authentication token of the currently authenticated user).
14+
The function is a thin wrapper around the `HTTP.request` function, constructing the
15+
correct URL and setting the authentication headers.
16+
17+
Arguments:
18+
19+
* `job::Job`: JuliaHub job (from [`JuliaHub.job`](@ref))
20+
21+
* `method::AbstractString`: HTTP method (gets directly passed to HTTP.jl)
22+
23+
* `uripath::AbstractString`: the path and query portion of the URL, which gets
24+
appended to the scheme and hostname port of the URL. Must start with a `/`.
25+
26+
* `body`: gets passed as the `body` argument to HTTP.jl
27+
28+
Keyword arguments:
29+
30+
$(_DOCS_authentication_kwarg)
31+
32+
* `extra_headers`: an iterable of extra HTTP headers, that gets concatenated
33+
with the list of necessary authentication headers and passed on to `HTTP.request`.
34+
35+
* Additional keyword arguments must be valid HTTP.jl keyword arguments and will
36+
get directly passed to the `HTTP.request` function.
37+
38+
!!! note
39+
40+
See the [manual section on jobs with exposed ports](@ref jobs-apis-expose-ports)
41+
and the `expose` argument to [`submit_job`](@ref).
42+
"""
43+
function request(
44+
job::Job,
45+
method::AbstractString,
46+
uripath::AbstractString,
47+
body::Any=UInt8[];
48+
auth::Authentication=__auth__(),
49+
extra_headers::Vector{Any}=[],
50+
kwargs...,
51+
)
52+
if isnothing(job.hostname)
53+
throw(ArgumentError("Job '$(job.id)' does not expose a HTTPS port."))
54+
end
55+
if !startswith(uripath, "/")
56+
throw(ArgumentError("'uripath' must start with a /, got: '$uripath'"))
57+
end
58+
return Mocking.@mock _http_request_mockable(
59+
method,
60+
string("https://", job.hostname, uripath),
61+
[_authheaders(auth)..., extra_headers...],
62+
body;
63+
kwargs...,
64+
)
65+
end
66+
67+
_http_request_mockable(args...; kwargs...) = HTTP.request(args...; kwargs...)

0 commit comments

Comments
 (0)