Skip to content

feat: serverless deploy directory - #97

Open
D1-3105 wants to merge 6 commits into
rc/serverlessfrom
feat/serverless-deploy-directory
Open

feat: serverless deploy directory#97
D1-3105 wants to merge 6 commits into
rc/serverlessfrom
feat/serverless-deploy-directory

Conversation

@D1-3105

@D1-3105 D1-3105 commented Aug 21, 2026

Copy link
Copy Markdown

Support directory deployment
Env vars propagation using deploy target
.runwareignore
volumes

`deploy ./app.py` shipped exactly one file: packPythonFile stat'd a single
path, rejected a directory and wrote one zip entry. An app that imported a
helper module or read a data file of its own could not be deployed at all,
even though the builder unpacks the archive, puts every top-level entry on
PYTHONPATH and hands them to MLflow code_paths -- it has always been built
for a project tree.

packDirectory walks the whole source directory instead. --src-dir sets the
root and defaults to the working directory; the entry file must live inside
it, and a relative path is resolved there rather than against the working
directory, because modelFile is a path *inside* the codebase -- that is what
the field means on the wire and what the builder looks up in the zip.

Exclusions are gitignore semantics via go-git's matcher, which the .gitignore
fallback needs: a bare `*.pyc` has to match at any depth, and dockerignore
matchers do not do that. .runwareignore wins outright when present rather
than unioning with .gitignore, so the result stays possible to reason about.
An excluded directory is pruned rather than walked, which both keeps a 300MB
.venv from costing a stat per file and reproduces git's own rule that a
negation cannot re-include a file whose parent directory is excluded.

.env files and .git are excluded absolutely, ahead of the matcher, so no rule
-- not even an explicit `!.env` -- can re-include them. The build pod unpacks
whatever it is sent, and the builder keeping top-level dotfiles out of the
*image* is a later step, not a promise about the build.

Caps are per-file and total, and the total error names the largest files and
points at .runwareignore: "your upload is too big" without saying what filled
it leaves the caller hunting through a deep tree.
The public API has taken a `volumes` array on app create for a while and the
deployer renders each entry as a hostPath mount, but the CLI had no way to say
so -- which made it unusable for any app that downloads weights. Anything
fetched at runtime belongs on a volume: the app runs in a sandbox whose
filesystem is part of the checkpointed state, so an unmounted download is
copied into every checkpoint AND re-fetched on every cold start.

--volume takes an absolute path and repeats. The path is the volume's whole
identity -- there is no name, because the path is what the app opens and what
the node-local directory is keyed by -- so two entries resolving to the same
place are a mistake rather than a merge.

buildVolumes mirrors the server's checks locally: absolute, not root, within
the length limits, allowed characters, no duplicates, no overlaps, at most 30.
The duplication is deliberate and policy.go gives the reason for its own copy:
a code deployment can build for up to ninety minutes, and learning only then
that two mounts overlap wastes all of it. A shared prefix is not an overlap --
/data/weights-old sits beside /data/weights, not inside it.
An app's environment is frozen into the version snapshot that create mints, and
that snapshot is what the deployer renders the worker from. Nothing produces a
second version -- /versions and /builds are GET-only, and POST /deploy says
outright that it creates no version and re-applies the existing image -- so a
variable set through the /environment-variables endpoints after the app exists
is stored, listed back by `apps env set`/`env list`, and never reaches a pod.

Verified against a live app: version 1's snapshot held environmentVariables {}
one second before HF_TOKEN was set, a redeploy of that version logged "redeploy
completed", and the workload still had no HF_TOKEN on it.

So the create request is the only route into a worker, and the CLI had no way to
populate it. --env takes KEY=VALUE and repeats; only the first separator splits,
because a value may legitimately contain '=' (base64 padding, a DSN).

--env-file exists because the argv form cannot hold a secret: a value passed as
--env is visible in the process list to every other user on the machine for as
long as the call runs, and the shell records it in history. It reads KEY=VALUE
lines, skips blanks and comments, and absorbs a leading `export ` so a
shell-sourced file can be pasted in as-is. An inline --env wins over a file
entry of the same name, being the more specific statement.

Names are validated against the server's EnvironmentVariableName rule so a name
this accepts is one the API can store, rather than a 422 after the archive has
already been uploaded.
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. To trigger a review, include coderabbit-review in the PR description. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f80bae28-e1ea-44b7-b5e0-2bfebb2423fc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds directory-based serverless deployments with ignore rules, environment variables, and persistent volumes.

Changes:

  • Packages complete source directories with .runwareignore support and size limits.
  • Adds deploy-time environment-variable and volume configuration.
  • Expands tests, documentation, and dependencies.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/cmd/serverless/volume.go Validates volume mount paths.
internal/cmd/serverless/volume_test.go Tests volume validation.
internal/cmd/serverless/pack.go Implements directory packaging and ignore rules.
internal/cmd/serverless/pack_test.go Tests directory archive behavior.
internal/cmd/serverless/envvar.go Parses deploy environment variables.
internal/cmd/serverless/envvar_test.go Tests environment-variable parsing.
internal/cmd/serverless/deploy.go Adds deployment flags and request propagation.
internal/api/serverless/client.go Exposes the generated volume type.
go.mod Adds gitignore support dependencies.
go.sum Updates dependency checksums.
docs/runware_serverless_deploy.md Documents the new deployment workflow.
Suppressed comments (4)

internal/cmd/serverless/pack.go:299

  • The model-file exception does not work when an ignore rule matches one of its parent directories. For example, with modelFileRel == "src/app.py" and src/ in .runwareignore, WalkDir prunes src here before it can visit the model file, so the resulting deployment cannot contain its required entry point. Keep descending when the ignored directory is an ancestor of modelFileRel, while still excluding its other contents.
		if rel != modelFileRel && matcher.Match(segments, d.IsDir()) {
			// Pruning the directory rather than descending is what keeps a
			// .venv from costing a stat per file. It also reproduces git's own
			// rule -- "it is not possible to re-include a file if a parent
			// directory of that file is excluded" -- so a `!node_modules/keep.js`

internal/cmd/serverless/pack.go:313

  • A symlink used as the model file passes os.Stat in relativeModelFile, but WalkDir reports the entry as a symlink and this branch silently omits it. If any other file is packed, deployment proceeds with a modelFile path absent from the archive and fails later in the builder. Either securely pack a resolved in-tree target or reject a symlink model file during local validation.
		if !d.Type().IsRegular() {
			return nil

internal/cmd/serverless/envvar.go:111

  • The API's maxLength and this error message define this limit in characters, but len(value) counts UTF-8 bytes. Valid non-ASCII values can therefore be rejected well below 4096 characters—for example, 4096 é characters are 8192 bytes. Count runes instead so local validation matches the API contract.
	case len(value) > maxEnvValueLen:
		return "", "", fmt.Errorf("value for %q exceeds %d characters", name, maxEnvValueLen)

internal/cmd/serverless/pack.go:404

  • The aggregate size is checked only from the earlier Stat results. Files can grow after the walk while remaining below the 10 MiB per-file cap; for example, three files measured at 8 MiB can each grow to 10 MiB and produce a 30 MiB payload despite the 25 MiB total limit. Track the bytes copied across entries and enforce maxPackTotalBytes while writing as well.
	// Capped in case the file grew between the walk and here, so a file that
	// changes underneath us cannot defeat the limit checked above.
	written, err := io.Copy(w, io.LimitReader(src, maxPackEntryBytes+1))

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/cmd/serverless/pack.go
Comment thread internal/cmd/serverless/envvar.go Outdated
Comment thread internal/cmd/serverless/pack.go Outdated
Comment thread internal/cmd/serverless/envvar.go Outdated
Comment thread docs/runware_serverless_deploy.md Outdated
Comment thread internal/cmd/serverless/pack.go
Renames and consolidation:
  * envvar.go is gone. Its create-time helpers now live in env.go beside the
    `apps env` command group, matching internal/api/serverless/env.go, with a
    divider recording why both belong in one file: the commands change what the
    API reports, and only the create request reaches a running worker.

Correctness:
  * The model file survives an ignore rule that matches one of its ANCESTORS.
    The exemption keyed on the file's own path, but WalkDir meets the directory
    first and prunes it, so `dist/` plus a model file in dist/ produced an
    archive without its entry point. Ancestor directories are now descended into
    while everything else inside them stays excluded.
  * A symlinked model file is rejected instead of silently dropped. It passed the
    local stat and was then skipped by the walk as a non-regular file, uploading
    an archive whose declared entry point was absent -- a 422 from the builder
    after the upload rather than a message here.
  * Ignore lines reach the parser verbatim, CR aside. Trimming rewrote valid
    gitignore patterns: an escaped trailing space lost its escape and a leading
    space is part of the pattern. Blanks and comments are ParsePattern's business.
  * File modes are preserved with zip.FileInfoHeader + CreateHeader. zw.Create
    writes mode 0, so an entrypoint script or helper binary in a codebase arrived
    without its executable bit and failed at run time with nothing about the
    archive to explain it.
  * The archive total is enforced while writing, not only from the walk's stats.
    Files can grow between being measured and being read, and several staying
    under the per-file cap can still cross the total.
  * --env-file no longer trims the assignment. TrimSpace ran on the whole line,
    so it silently rewrote values; the trimmed copy now only decides whether a
    line carries an assignment at all.
  * One matching pair of surrounding quotes is stripped from a value. Files
    routinely quote them and shells strip them when sourcing, so `HF_TOKEN="hf_x"`
    was sending the quotes as part of the token -- a 401 in the pod with nothing
    in the logs. Unbalanced or inner quotes are left alone.
  * Name and value limits count runes. The API's maxLength is characters, so len()
    rejected a valid non-ASCII value at half the documented limit.

Every fix has a test, including the reviewer's own case for the excluded-directory
model file across gitignore, runwareignore and built-in default rules.
Per review: what a project keeps out of version control is a different question
from what it ships to a builder. A generated asset the app needs at run time is
a routine .gitignore entry, and a file silently missing from a deployment
because of a rule written for git is a surprise that cannot be debugged from
the outside -- the archive is already uploaded by then.

So exclusions are opt-in and local to .runwareignore. The built-in defaults
stay: they cover output nobody means to ship (__pycache__, *.pyc, .venv,
node_modules, the tool caches) and a project rule can still override them.
.env and .git remain absolutely excluded ahead of the matcher.

.gitignore itself now ships like any other file -- it is not special, just not
obeyed -- which the replacement test asserts alongside the rules being ignored.
@D1-3105
D1-3105 requested a review from danmrichards August 21, 2026 23:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants