Skip to content

Commit

Permalink
feat: add interface to expose HTTP ports on batch jobs (#52)
Browse files Browse the repository at this point in the history
  • Loading branch information
mortenpi authored Jun 27, 2024
1 parent 115f14e commit 614c5b1
Show file tree
Hide file tree
Showing 17 changed files with 834 additions and 44 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

* The `JuliaHub.authenticate` function now supports a two-argument form, where you can pass the JuliaHub token in directly, bypassing interactive authentication. (??)
* The `JuliaHub.authenticate` function now supports a two-argument form, where you can pass the JuliaHub token in directly, bypassing interactive authentication. (#58)
* 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)

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

Expand Down
57 changes: 57 additions & 0 deletions docs/src/guides/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,60 @@ To actually fetch the contents of a file, you can use the [`download_job_file`](
```@setup job-outputs
empty!(Main.MOCK_JULIAHUB_STATE)
```

## [Opening ports on batch jobs](@id jobs-batch-expose-port)

```@setup job-expose-port
Main.MOCK_JULIAHUB_STATE[:jobs] = Dict(
"jr-xf4tslavut" => Dict(
"proxy_link" => "https://afyux.launch.juliahub.app/"
)
)
import JuliaHub
job = JuliaHub.job("jr-xf4tslavut")
```

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.
This could be used to run "interactive" jobs that respond to user inputs, or to poll the job for data.

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.

```@example job-expose-port
import JuliaHub # hide
job = JuliaHub.submit_job(
JuliaHub.script"""
using Oxygen, HTTP
PORT = parse(Int, ENV["PORT"])
@get "/" function(req::HTTP.Request)
return "success"
end
serve(; host="0.0.0.0", port = PORT)
""",
expose = 8080,
)
```

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)).

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.

```@repl job-expose-port
JuliaHub.request(job, "GET", "/")
```

!!! note "502 Bad Gateway"

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.

!!! tip "HTML page"

If the server can serve a HTML page, then you can also access the job in the browser.
The web UI will also have a "Connect" link, like for other interactive applications.

!!! note "Pricing"

Jobs that expose ports may be priced differently per hour than batch jobs that do not open ports.

```@setup job-expose-port
empty!(Main.MOCK_JULIAHUB_STATE)
```
11 changes: 11 additions & 0 deletions docs/src/reference/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ classDef user fill:lightgray
job = JuliaHub.job(job)
```

## [Jobs with exposed ports](@id jobs-apis-expose-ports)

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)).

If the job exposes a port, it can be accessed at a dedicated hostname (see the `.hostname` property of the [`Job`](@ref) object).
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).
In most cases, your requests to the job also need to be authenticated (see also the [`JuliaHub.request`](@ref) function).

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)

## Reference

```@docs
Expand All @@ -70,6 +80,7 @@ JuliaHub.Job
JuliaHub.JobStatus
JuliaHub.JobFile
JuliaHub.FileHash
JuliaHub.request
```

## Index
Expand Down
1 change: 1 addition & 0 deletions src/JuliaHub.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ include("node.jl")
include("jobsubmission.jl")
include("PackageBundler/PackageBundler.jl")
include("jobs/jobs.jl")
include("jobs/request.jl")
include("jobs/logging.jl")
include("jobs/logging-kafka.jl")
include("jobs/logging-legacy.jl")
Expand Down
7 changes: 7 additions & 0 deletions src/authentication.jl
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,10 @@ function reauthenticate!(
auth._tokenpath = new_auth._tokenpath
return auth
end

# This can be interpolated into the docstrings of functions that take the
# auth::Authentication = __auth__() keyword argument.
const _DOCS_authentication_kwarg = """
* `auth :: Authentication`: optional authentication object (see
[the authentication section](@ref authentication) for more information)
"""
43 changes: 39 additions & 4 deletions src/batchimages.jl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Base.@kwdef struct BatchImage
_cpu_image_key::Union{String, Nothing}
_gpu_image_key::Union{String, Nothing}
_is_product_default::Bool
_interactive_product_name::Union{String, Nothing}
end

function Base.show(io::IO, image::BatchImage)
Expand All @@ -33,6 +34,10 @@ function Base.show(io::IO, ::MIME"text/plain", image::BatchImage)
print(io, '\n', " image: ", image.image)
isnothing(image._cpu_image_key) || print(io, "\n CPU image: ", image._cpu_image_key)
isnothing(image._gpu_image_key) || print(io, "\n GPU image: ", image._gpu_image_key)
if !isnothing(image._interactive_product_name)
print(io, "\n Features:")
print(io, "\n - Expose Port: ✓")
end
end

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

function _is_interactive_batch_app(app::DefaultApp)
# Like _is_batch_app, this should return false for JuliaHub <= 6.1
compute_type = get(app._json, "compute_type_name", nothing)
input_type = get(app._json, "input_type_name", nothing)
compute_type in ("distributed-interactive",) && (input_type == "userinput")
end

function _batchimages_62(auth::Authentication)
image_groups = _product_image_groups(auth)
batchapps = filter(_is_batch_app, _apps_default(auth))
batchapps, interactiveapps = let apps = _apps_default(auth)
filter(_is_batch_app, apps), filter(_is_interactive_batch_app, apps)
end
batchimages = map(batchapps) do app
product_name = app._json["product_name"]
image_group = app._json["image_group"]
images = get(image_groups, image_group, [])
if isempty(images)
@warn "Invalid image_group '$image_group' for '$product_name'" app
end
matching_interactive_app = filter(interactiveapps) do app
get(app._json, "image_group", nothing) == image_group
end
interactive_product_name = if length(matching_interactive_app) > 1
# If there are multiple interactive products configured for a batch product
# we issue a warning and disable the 'interactive' compute for it (i.e. the user
# won't be able to start jobs that require a port to be exposed until the configuration
# issue is resolved).
@warn "Multiple matching interactive apps for $(app)" image_group matches =
matching_interactive_app
nothing
elseif isempty(matching_interactive_app)
# If we can't find a matching 'distributed-interactive' product, we disable the
# ability for the user to expose a port with this image.
nothing
else
only(matching_interactive_app)._json["product_name"]
end
map(images) do (display_name, imagekey)
BatchImage(;
product=product_name, image=display_name,
_cpu_image_key=imagekey.cpu, _gpu_image_key=imagekey.gpu,
_is_product_default=imagekey.isdefault,
product = product_name,
image = display_name,
_cpu_image_key = imagekey.cpu,
_gpu_image_key = imagekey.gpu,
_is_product_default = imagekey.isdefault,
_interactive_product_name = interactive_product_name,
)
end
end
Expand Down
27 changes: 27 additions & 0 deletions src/jobs/jobs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ Represents a single job submitted to JuliaHub. Objects have the following proper
explicitly set)
* `files :: Vector{JobFiles}`: a list of [`JobFile`](@ref) objects, representing the input and
output files of the job (see: [`job_files`](@ref), [`job_file`](@ref), [`download_job_file`](@ref)).
* `hostname :: Union{String, Nothing}`: for jobs that expose a port over HTTP, this will be set to the
hostname of the job (`nothing` otherwise; see: [the relevant section in the manual](@ref jobs-batch-expose-port))
See also: [`job`](@ref), [`jobs`](@ref).
Expand All @@ -213,6 +215,7 @@ struct Job
env::Dict{String, Any}
results::String
files::Vector{JobFile}
hostname::Union{String, Nothing}
_timestamp_submit::Union{String, Nothing}
_timestamp_start::Union{String, Nothing}
_timestamp_end::Union{String, Nothing}
Expand All @@ -239,13 +242,36 @@ struct Job
)
end
end
hostname = let proxy_link = get(j, "proxy_link", "")
if isempty(proxy_link)
nothing
else
uri = URIs.URI(proxy_link)
checks = (
uri.scheme == "https",
!isempty(uri.host),
isempty(uri.path) || uri.path == "/",
isempty(uri.query),
isempty(uri.fragment),
)
if !all(checks)
# Some jobs can have non-empty proxy links that are not proper hostnames.
# We'll just ignore those for now.
@debug "Unable to parse 'proxy_link' JSON for job '$jobname': '$(proxy_link)'"
nothing
else
uri.host
end
end
end
return new(
jobname,
_get_json_or(j, "jobname_alias", Union{String, Nothing}, nothing),
JobStatus(_json_get(j, "status", String; var)),
inputs,
outputs,
haskey(j, "files") ? JobFile.(jobname, j["files"]; var) : JobFile[],
hostname,
# Under some circumstances, submittimestamp can also be nothing, even though that is
# weird.
_json_get(j, "submittimestamp", Union{String, Nothing}; var), # TODO: drop Nothing?
Expand All @@ -270,6 +296,7 @@ function Base.show(io::IO, ::MIME"text/plain", job::Job)
print(io, '\n', " submitted: ", job._timestamp_submit)
isnothing(job._timestamp_start) || print(io, '\n', " started: ", job._timestamp_start)
isnothing(job._timestamp_end) || print(io, '\n', " finished: ", job._timestamp_end)
isnothing(job.hostname) || print(io, '\n', " hostname: ", job.hostname)
# List of job files:
if !isempty(job.files)
print(io, '\n', " files: ")
Expand Down
67 changes: 67 additions & 0 deletions src/jobs/request.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
function request(
job::Job,
method::AbstractString,
uripath::AbstractString,
[body];
[auth::Authentication],
[extra_headers],
kwargs...
) -> HTTP.Response
Performs an authenticated HTTP request against the HTTP server exposed by the job
(with the authentication token of the currently authenticated user).
The function is a thin wrapper around the `HTTP.request` function, constructing the
correct URL and setting the authentication headers.
Arguments:
* `job::Job`: JuliaHub job (from [`JuliaHub.job`](@ref))
* `method::AbstractString`: HTTP method (gets directly passed to HTTP.jl)
* `uripath::AbstractString`: the path and query portion of the URL, which gets
appended to the scheme and hostname port of the URL. Must start with a `/`.
* `body`: gets passed as the `body` argument to HTTP.jl
Keyword arguments:
$(_DOCS_authentication_kwarg)
* `extra_headers`: an iterable of extra HTTP headers, that gets concatenated
with the list of necessary authentication headers and passed on to `HTTP.request`.
* Additional keyword arguments must be valid HTTP.jl keyword arguments and will
get directly passed to the `HTTP.request` function.
!!! note
See the [manual section on jobs with exposed ports](@ref jobs-apis-expose-ports)
and the `expose` argument to [`submit_job`](@ref).
"""
function request(
job::Job,
method::AbstractString,
uripath::AbstractString,
body::Any=UInt8[];
auth::Authentication=__auth__(),
extra_headers::Vector{Any}=[],
kwargs...,
)
if isnothing(job.hostname)
throw(ArgumentError("Job '$(job.id)' does not expose a HTTPS port."))
end
if !startswith(uripath, "/")
throw(ArgumentError("'uripath' must start with a /, got: '$uripath'"))
end
return Mocking.@mock _http_request_mockable(
method,
string("https://", job.hostname, uripath),
[_authheaders(auth)..., extra_headers...],
body;
kwargs...,
)
end

_http_request_mockable(args...; kwargs...) = HTTP.request(args...; kwargs...)
Loading

0 comments on commit 614c5b1

Please sign in to comment.