Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/runware_serverless_deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ minWorkers=0 stays scaled to zero until the first invoke.
--container cannot be combined with an entry file, --src-dir, --base-image, or
--requirement.

--from takes the URL of a packaged source: a zip whose root holds the app's
files and a runware.yaml recording the deploy options. The CLI fetches it,
reads that file, and deploys what it describes, so a published example deploys
in one command instead of a clone and a command. Flags still win over the
file, so the same archive goes to another GPU type or another --id without
being edited first. An --env-var or --env-file sets one key over the
manifest's and leaves the rest, while --volume, --requirement and --secret
replace the manifest's whole list. The archive supplies the source directory,
so --from cannot be combined with an entry file, --container, or --src-dir.

--from only creates. A manifest names an id, and deploying it over an
application that already has that id would roll the archive out as that
application's next version, running this code with the secrets, environment
variables and volumes it already has. So an existing id is refused: pass --id
to create a separate application, or unpack the archive and deploy the
directory to update the one that is there.

A runware.yaml carries no secret values. Its secrets name what the app needs,
and each one has to already exist in the environment. A manifest key this CLI
does not know is an error rather than a silent omission, which is what
configVersion is there to tell apart.

Exclude what the app does not need with a .runwareignore file at the root of the
source directory; it takes gitignore syntax. A .gitignore is NOT consulted --
what a project keeps out of version control is a different question from what it
Expand Down Expand Up @@ -124,6 +146,13 @@ runware serverless deploy [file] [flags]

# wait until the first rollout is active or failed
runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait --timeout 10m

# deploy a packaged source, reading its id and options from its runware.yaml
runware serverless deploy --from https://runware.ai/docs/serverless/examples/echo.zip

# the same archive under another id and on another GPU type
runware serverless deploy --from https://runware.ai/docs/serverless/examples/echo.zip \
--id my-echo --gpu-type l40s
```

### Options
Expand All @@ -136,6 +165,7 @@ runware serverless deploy [file] [flags]
--env-file stringArray File of KEY=VALUE lines to read environment variables from (repeatable)
--env-var stringArray Environment variable as KEY=VALUE (repeatable)
--fallback-gpu-type string Secondary GPU type if the preferred type is unavailable
--from string URL of a packaged source to fetch and deploy as a new application, reading its runware.yaml
--gpu-type string GPU type ID (see 'serverless gpus'; required when creating)
--gpus-per-worker int32 GPUs allocated per worker (1, 2, 4, or 8) (default 1)
-h, --help help for deploy
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/serverless/apps_health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestAppsHealthFromAPI(t *testing.T) {
body = `{"data":[` + body + `]}`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := "/v1/apps"
path := testAppsPath
if command == appHealthShowCommand {
path += "/" + testAppID
}
Expand Down
168 changes: 147 additions & 21 deletions internal/cmd/serverless/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ import (
"github.com/spf13/cobra"
)

const deploySourceChoice = "pass an entry file or --container"
const deploySourceChoice = "pass an entry file, --container, or --from"

var codeOnlyDeployFlags = []string{
"src-dir",
"base-image",
"requirement",
}

// fromOnlyConflictFlags name a local source, which --from already supplies.
var fromOnlyConflictFlags = []string{
"src-dir",
"container",
}

// createOnlyDeployFlags apply to CreateApp only. On an existing app they are
// rejected so a leftover create invocation cannot wipe environment variables
// or silently no-op.
Expand Down Expand Up @@ -72,21 +78,47 @@ func (s deploySource) appSource(sourceID uuid.UUID) (serverlessapi.AppSourceUpse
}
}

func validateDeployArgs(cmd *cobra.Command, args []string, containerDir string) error {
func validateDeployArgs(cmd *cobra.Command, args []string, containerDir, from string) error {
hasFile := len(args) == 1
hasContainer := containerDir != ""
if hasFile == hasContainer {
if hasFile {
return fmt.Errorf("%s, not both", deploySourceChoice)
hasFrom := from != ""

chosen := 0
for _, picked := range []bool{hasFile, hasContainer, hasFrom} {
if picked {
chosen++
}
}
if chosen != 1 {
if chosen == 0 {
return fmt.Errorf("%s", deploySourceChoice)
}
return fmt.Errorf("%s, not more than one", deploySourceChoice)
}

if hasFrom {
for _, name := range fromOnlyConflictFlags {
if cmd.Flags().Changed(name) {
return fmt.Errorf("--%s names a local source; --from takes the source from the archive", name)
}
}
return fmt.Errorf("%s", deploySourceChoice)
// The source type is inside the archive, so the code-only flags cannot be
// judged yet. refuseCodeOnlyFlags runs again once the manifest is read.
return nil
}
if !hasContainer {
return nil
}
return refuseCodeOnlyFlags(cmd, "omit it when using --container")
}

// refuseCodeOnlyFlags rejects the flags a container deploy has no use for. A
// deploy that accepted and then discarded them would differ from the command
// the person wrote without saying so.
func refuseCodeOnlyFlags(cmd *cobra.Command, remedy string) error {
for _, name := range codeOnlyDeployFlags {
if cmd.Flags().Changed(name) {
return fmt.Errorf("--%s applies to code deploys only; omit it when using --container", name)
return fmt.Errorf("--%s applies to code deploys only; %s", name, remedy)
}
}
return nil
Expand Down Expand Up @@ -132,6 +164,7 @@ func newDeployCmd(logger *log.Logger) *cobra.Command {
availableWorkersPct int32
srcDir string
containerDir string
from string
volumes []string
secrets []string
envVars []string
Expand Down Expand Up @@ -182,6 +215,28 @@ minWorkers=0 stays scaled to zero until the first invoke.
--container cannot be combined with an entry file, --src-dir, --base-image, or
--requirement.

--from takes the URL of a packaged source: a zip whose root holds the app's
files and a runware.yaml recording the deploy options. The CLI fetches it,
reads that file, and deploys what it describes, so a published example deploys
in one command instead of a clone and a command. Flags still win over the
file, so the same archive goes to another GPU type or another --id without
being edited first. An --env-var or --env-file sets one key over the
manifest's and leaves the rest, while --volume, --requirement and --secret
replace the manifest's whole list. The archive supplies the source directory,
so --from cannot be combined with an entry file, --container, or --src-dir.

--from only creates. A manifest names an id, and deploying it over an
application that already has that id would roll the archive out as that
application's next version, running this code with the secrets, environment
variables and volumes it already has. So an existing id is refused: pass --id
to create a separate application, or unpack the archive and deploy the
directory to update the one that is there.

A runware.yaml carries no secret values. Its secrets name what the app needs,
and each one has to already exist in the environment. A manifest key this CLI
does not know is an error rather than a silent omission, which is what
configVersion is there to tell apart.

Exclude what the app does not need with a .runwareignore file at the root of the
source directory; it takes gitignore syntax. A .gitignore is NOT consulted --
what a project keeps out of version control is a different question from what it
Expand Down Expand Up @@ -255,15 +310,83 @@ paths and an invoke example once the application is active.`,
runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper

# wait until the first rollout is active or failed
runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait --timeout 10m`,
runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait --timeout 10m

# deploy a packaged source, reading its id and options from its runware.yaml
runware serverless deploy --from https://runware.ai/docs/serverless/examples/echo.zip

# the same archive under another id and on another GPU type
runware serverless deploy --from https://runware.ai/docs/serverless/examples/echo.zip \
--id my-echo --gpu-type l40s`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateWaitFlags(cmd, wait, timeout); err != nil {
return err
}
if err := validateDeployArgs(cmd, args, containerDir); err != nil {
if err := validateDeployArgs(cmd, args, containerDir, from); err != nil {
return err
}

var (
manifest deployManifest
manifestEnv map[string]string
)
if from != "" {
dir, cleanup, err := fetchSourceArchive(cmd.Context(), from)
if err != nil {
return err
}
defer cleanup()

if manifest, err = readDeployManifest(dir); err != nil {
return err
}

// The manifest's own values, for every flag the command line left
Comment thread
danmrichards marked this conversation as resolved.
Resolved
// alone. A passed flag wins, which is what lets one packaged source
// deploy on another GPU type without being edited first.
//
// Nothing here marks a flag as changed. The create-only flags are
Comment thread
danmrichards marked this conversation as resolved.
Resolved
// refused on an update by exactly that test, and a deploy from a
// packaged source only ever creates, so the manifest's own values
// always reach a create.
if manifest.Source.Type == string(serverlessapi.AppSourceTypeContainer) {
if err := refuseCodeOnlyFlags(cmd, "this archive holds a container source"); err != nil {
return err
}
}

args, srcDir, containerDir = manifest.localSource(dir)
Comment thread
Copilot marked this conversation as resolved.
Resolved
id = manifestString(cmd, "id", id, manifest.ID)
name = manifestString(cmd, "name", name, manifest.Name)
baseImage = manifestString(cmd, "base-image", baseImage, manifest.Build.BaseImage)
gpuType = manifestString(cmd, "gpu-type", gpuType, manifest.Compute.GpuType)
maxWorkers = manifestInt32(cmd, "max-workers", maxWorkers, manifest.Compute.MaxWorkers)
idleTTL = manifestInt32(cmd, "idle-ttl", idleTTL, manifest.Compute.IdleTTL)
scalingDelay = manifestInt32(cmd, "scaling-delay", scalingDelay, manifest.Compute.ScalingDelay)
if !cmd.Flags().Changed("requirement") {
requirements = manifest.Build.Requirements
}
if !cmd.Flags().Changed("volume") {
volumes = manifest.Volumes
}
// The map itself, not rendered as KEY=VALUE: a value that
// legitimately carries quotes would be unquoted a second time
// on the way back in.
//
// Passed whether or not --env-var was, because
// buildEnvironmentVariables reads it before --env-file and
// --env-var. One flag then overrides one key rather than
// dropping the whole set a template needs to run.
manifestEnv = manifest.EnvVars
if !cmd.Flags().Changed("secret") {
secrets = manifest.secretFlags()
}
}

if id == "" {
return fmt.Errorf("--id is required")
}
if err := validateGPUsPerWorkerFlag(cmd, gpusPerWorker); err != nil {
return err
}
Expand All @@ -288,7 +411,7 @@ paths and an invoke example once the application is active.`,

client := newServerlessClient(cmd, logger)

update, err := resolveDeployTarget(cmd, client, id, gpuType)
update, err := resolveDeployTarget(cmd, client, id, gpuType, from)
if err != nil {
return err
}
Expand Down Expand Up @@ -316,7 +439,7 @@ paths and an invoke example once the application is active.`,
if err != nil {
return err
}
appEnv, err = buildEnvironmentVariables(envFiles, envVars)
appEnv, err = buildEnvironmentVariables(envFiles, envVars, manifestEnv)
if err != nil {
return err
}
Expand Down Expand Up @@ -361,11 +484,11 @@ paths and an invoke example once the application is active.`,
IdleTtlSecs: idleTTL,
ScalingDelaySecs: scalingDelay,
GpuType: gpuType,
MinWorkers: optionalInt32Ptr(cmd, "min-workers", minWorkers),
GpusPerWorker: optionalInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker),
FallbackGpuType: optionalFlagStringPtr(cmd, "fallback-gpu-type", fallbackGPUType),
MinAvailableWorkers: optionalInt32Ptr(cmd, "min-available-workers", minAvailableWorkers),
AvailableWorkersPct: optionalInt32Ptr(cmd, "available-workers-pct", availableWorkersPct),
MinWorkers: manifestInt32Ptr(cmd, "min-workers", minWorkers, manifest.Compute.MinWorkers),
GpusPerWorker: manifestInt32Ptr(cmd, "gpus-per-worker", gpusPerWorker, manifest.Compute.GpusPerWorker),
FallbackGpuType: manifestStringPtr(cmd, "fallback-gpu-type", fallbackGPUType, manifest.Compute.FallbackGpuType),
MinAvailableWorkers: manifestInt32Ptr(cmd, "min-available-workers", minAvailableWorkers, manifest.Compute.MinAvailableWorkers),
AvailableWorkersPct: manifestInt32Ptr(cmd, "available-workers-pct", availableWorkersPct, manifest.Compute.AvailableWorkersPct),
},
})
}
Expand Down Expand Up @@ -427,6 +550,7 @@ paths and an invoke example once the application is active.`,

cmd.Flags().StringVar(&srcDir, "src-dir", "", "Directory to package as the application source (default: the working directory; code deploys only)")
cmd.Flags().StringVar(&containerDir, "container", "", "Directory whose root contains Dockerfile and container.yaml")
cmd.Flags().StringVar(&from, "from", "", "URL of a packaged source to fetch and deploy as a new application, reading its "+manifestFile)
cmd.Flags().StringArrayVar(&volumes, "volume", nil, "Absolute path inside the app backed by persistent node-local storage; immutable after create (repeatable)")
cmd.Flags().StringArrayVar(&secrets, "secret", nil, "Secret of the same environment to attach at create, as NAME or NAME=ENV_VAR (repeatable)")
cmd.Flags().StringArrayVar(&envVars, envVarFlag, nil, "Environment variable as KEY=VALUE (repeatable)")
Expand All @@ -449,18 +573,14 @@ paths and an invoke example once the application is active.`,
cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application")
addEnvironmentFlag(cmd, cmd.Flags(), logger, environmentFlagHelp)

if err := cmd.MarkFlagRequired("id"); err != nil {
panic(err)
}

return cmd
}

// resolveDeployTarget reports whether the deploy updates an existing copy or
// creates one. Before the archive is uploaded it refuses a copy that is still
// deleting, create-only flags on an update, and an unknown --env or a missing
// --gpu-type on a create.
func resolveDeployTarget(cmd *cobra.Command, client *serverlessapi.Client, id, gpuType string) (update bool, err error) {
func resolveDeployTarget(cmd *cobra.Command, client *serverlessapi.Client, id, gpuType, from string) (update bool, err error) {
app, err := existingApp(cmd.Context(), client, id)
if err != nil {
return false, err
Expand All @@ -469,6 +589,12 @@ func resolveDeployTarget(cmd *cobra.Command, client *serverlessapi.Client, id, g
if app.Status == serverlessapi.AppStatusDeleting {
return false, fmt.Errorf("application %q is deleting; wait until it is deleted, then deploy", id)
}
if from != "" {
return false, fmt.Errorf(
"application %q already exists, and --from only creates: deploying would roll out this archive as the next version of that application, which keeps the secrets, environment variables and volumes it has now. Pass --id to create a separate application, or unpack the archive and deploy the directory to update that one",
id,
)
}
return true, validateUpdateDeployFlags(cmd)
}
if err := requireEnvironment(cmd.Context(), client, commandEnvironment(cmd)); err != nil {
Expand Down
Loading
Loading