Skip to content
Merged
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,43 @@ files, migrations directory, and README). Use a template to add
language-specific files: `javascript` (aliases: `js`, `node`, `nodejs`),
`nextjs`, `python`, or `ruby`.

## Project configuration (`volcano-config.yaml`)

`volcano config deploy` reconciles declarative project configuration
(`volcano/volcano-config.yaml` or `./volcano-config.yaml`) against the active
target — the same manifest applies to local mode and cloud.

Functions may declare scheduled invocations. `name` and `cron` are required;
`enabled` (default `true`), `payload`, and `regions` are optional. A function
entry is valid if it sets `public` **or** declares at least one scheduler.

```yaml
version: 1
functions:
- name: hello
public: false
schedulers:
- name: refresh-cache # required, unique per function (the reconcile key)
cron: "*/5 * * * *"
enabled: true
payload: { job: refresh }
regions: [us-east-1] # omit to let the server pick one deployed region
```

Reconciliation follows one rule that mirrors the server: **fields you declare
are enforced; fields you omit are left server-managed.** An omitted `enabled`,
`payload`, or `regions` keeps whatever the scheduler already has on the server
(on first create the server applies its defaults — `enabled: true`, an empty
payload, and one chosen region). In particular, `config deploy` will not
re-enable a scheduler you disabled out of band unless the manifest sets
`enabled: true`. `cron` is always required and enforced.

Reconciliation is also **non-destructive**: it creates and updates the
schedulers a function declares (matched by `name`, preserving the scheduler ID)
but never deletes or disables one. A scheduler the manifest no longer declares
is left running; to remove or disable one, use the imperative commands
(`volcano functions schedulers delete` / `disable`).

## Contributing

See `CONTRIBUTING.md` for local workflows, generated-code guidance, release
Expand Down
30 changes: 22 additions & 8 deletions internal/cmd/localmode/localmode.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,26 @@ import (

// NewStart returns the start command.
func NewStart(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
var image string
cmd := &cobra.Command{
Use: "start",
Short: "Start the local Volcano development environment",
Long: `Start PostgreSQL, Redis, and the Volcano local-mode server with Docker Compose.

To pin or select a specific server image, set VOLCANO_IMAGE:
VOLCANO_IMAGE=kong/volcano:local-nightly volcano start`,
To run a specific or locally-built server image, use --image (highest precedence)
or set VOLCANO_IMAGE:
volcano start --image kong/volcano:local-dev
VOLCANO_IMAGE=kong/volcano:local-nightly volcano start

An explicitly selected image must already exist locally: the CLI never pulls an
unpublished local-mode image and fails fast if it is missing.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return localmodecore.NewService(deps).Start(cmd.Context(), cmd.OutOrStdout())
return localmodecore.NewService(deps, localmodecore.WithImage(image)).Start(cmd.Context(), cmd.OutOrStdout())
},
}
cmd.Flags().StringVar(&image, "image", "", "Local-mode server image to run (overrides VOLCANO_IMAGE and the bundled default; must already exist locally)")
return cmd
}

// NewStatus returns the status command.
Expand Down Expand Up @@ -58,13 +66,19 @@ Use --clean to also remove all data volumes and local dev state.`,

// NewRestart returns the restart command.
func NewRestart(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
var image string
cmd := &cobra.Command{
Use: "restart",
Short: "Restart the local Volcano development environment",
Long: "Stop and start the local Volcano development environment while preserving data.",
Args: cobra.NoArgs,
Long: `Stop and start the local Volcano development environment while preserving data.

Use --image (or VOLCANO_IMAGE) to select the server image; an explicitly
selected image must already exist locally and is never pulled.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return localmodecore.NewService(deps).Restart(cmd.Context(), cmd.OutOrStdout())
return localmodecore.NewService(deps, localmodecore.WithImage(image)).Restart(cmd.Context(), cmd.OutOrStdout())
},
}
cmd.Flags().StringVar(&image, "image", "", "Local-mode server image to run (overrides VOLCANO_IMAGE and the bundled default; must already exist locally)")
return cmd
}
14 changes: 14 additions & 0 deletions internal/function/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,20 @@ func (s Service) CreateSchedulerByID(ctx context.Context, functionID uuid.UUID,
return scheduler, nil
}

// UpdateSchedulerByID updates one scheduler by ID, preserving the scheduler UUID.
func (s Service) UpdateSchedulerByID(ctx context.Context, functionID, schedulerID uuid.UUID, input api.FunctionSchedulerInput) (*apiclient.FunctionScheduler, error) {
authenticated, err := s.sessions.CurrentProject()
if err != nil {
return nil, err
}

scheduler, err := authenticated.API.UpdateFunctionScheduler(ctx, authenticated.ProjectID, functionID, schedulerID, input)
if err != nil {
return nil, fmt.Errorf("failed to update scheduler: %w", err)
}
return scheduler, nil
}

// EnableScheduler enables one scheduler for a function.
func (s Service) EnableScheduler(ctx context.Context, identifier string, schedulerID uuid.UUID) (*apiclient.FunctionScheduler, error) {
return s.setSchedulerEnabled(ctx, identifier, schedulerID, true)
Expand Down
52 changes: 45 additions & 7 deletions internal/localmode/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,57 @@ func (s Service) composeEnvironment() ([]string, string, error) {
}
env = append(env, overrides...)

image := defaultVolcanoImage
if fileImage, ok := envValue(overrides, "VOLCANO_IMAGE"); ok && strings.TrimSpace(fileImage) != "" {
image = strings.TrimSpace(fileImage)
}
if processImage := strings.TrimSpace(s.getenv("VOLCANO_IMAGE")); processImage != "" {
image = processImage
}
image, _ := s.resolveImage()
env = withoutEnvKey(env, "VOLCANO_IMAGE")
env = append(env, "VOLCANO_IMAGE="+image)

return env, image, nil
}

// resolveImage returns the local-mode server image to run and whether it is a
// custom image (i.e. differs from the bundled default). Precedence (highest
// first): explicit image (WithImage/--image) > VOLCANO_IMAGE process env >
// project .env.local > defaultVolcanoImage. A custom image is treated as
// local-only: it is never pulled and must already exist locally. The bundled
// default is left to Docker Compose's normal pull-if-missing behavior even when
// it is selected explicitly.
func (s Service) resolveImage() (string, bool) {
image := defaultVolcanoImage
switch {
case s.image != "":
image = s.image
case strings.TrimSpace(s.getenv("VOLCANO_IMAGE")) != "":
image = strings.TrimSpace(s.getenv("VOLCANO_IMAGE"))
default:
if overrides, err := localEnvOverrides(); err == nil {
if fileImage, ok := envValue(overrides, "VOLCANO_IMAGE"); ok && strings.TrimSpace(fileImage) != "" {
image = strings.TrimSpace(fileImage)
}
}
}
return image, image != defaultVolcanoImage
}

// imageExistsLocally reports whether a Docker image reference is present in the
// local image store. It never contacts a registry.
func (s Service) imageExistsLocally(ctx context.Context, ref string) bool {
_, err := s.runDocker(ctx, "image", "inspect", ref)
return err == nil
}

// ensureCustomImageAvailable fails fast when an explicitly selected (custom)
// local-mode image is not present locally. The CLI never pulls unpublished
// local-mode images, so this surfaces an actionable build message instead of a
// confusing registry-pull error. The bundled default is left to Compose's
// normal pull-if-missing behavior even when selected explicitly.
func (s Service) ensureCustomImageAvailable(ctx context.Context) error {
image, customImage := s.resolveImage()
if customImage && !s.imageExistsLocally(ctx, image) {
return fmt.Errorf("image %q not found locally; the CLI does not pull unpublished local-mode images. Build it (e.g. in volcano-hosting: make docker-build DOCKER_TAG=<tag>) and ensure the tag matches, or run `docker pull %s` first if it is published", image, image)
}
return nil
}

func (s Service) startDockerServices(ctx context.Context, env []string) error {
composePath, cleanup, err := s.writeComposeFile()
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/localmode/restart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package localmode
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"slices"
Expand Down Expand Up @@ -72,3 +73,43 @@ func TestRestartStopsBeforeStarting(t *testing.T) {
require.NoError(t, service.Restart(context.Background(), &out))
assert.Equal(t, []string{"down", "up"}, order)
}

func TestRestartFailsBeforeTeardownWhenCustomImageMissing(t *testing.T) {
setLocalDevTestHome(t)
withTempWorkingDir(t)

runner := &fakeCommandRunner{
run: func(_ context.Context, command Command) ([]byte, error) {
switch {
case commandIs(command, "docker", "inspect", "--format={{.State.Running}}", serverContainerName):
return []byte("true\n"), nil
case commandIs(command, "docker", "version"):
return nil, nil
case commandIs(command, "docker", "image", "inspect", "kong/volcano:local-dev"):
return nil, errors.New("Error: No such image: kong/volcano:local-dev")
case commandIsComposeDown(command, false):
t.Fatalf("compose down must not run when the custom image is missing")
return nil, nil
case command.Name == "docker" && slices.Contains(command.Args, "up"):
t.Fatalf("compose up must not run when the custom image is missing")
return nil, nil
default:
return nil, nil
}
},
}

var out bytes.Buffer
service := NewService(
cliruntime.Deps{},
WithDockerRunner(runner),
WithImage("kong/volcano:local-dev"),
WithEnvironment(func() []string { return []string{"PATH=/bin"} }, func(string) string { return "" }),
WithTempDir(t.TempDir()),
)

err := service.Restart(context.Background(), &out)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found locally")
assert.False(t, runner.calledWithArg("docker", "down"), "environment must not be torn down on a bad --image")
}
29 changes: 29 additions & 0 deletions internal/localmode/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type Service struct {
environ func() []string
getenv func(string) string
tempDir string
image string
}

// Option configures a Service.
Expand Down Expand Up @@ -106,6 +107,16 @@ func WithTempDir(tempDir string) Option {
}
}

// WithImage sets an explicit local-mode server image, taking precedence over the
// VOLCANO_IMAGE environment variable, any project .env.local value, and the
// bundled default. An empty value is ignored. An explicitly selected image is
// never pulled: it must already exist locally (see Start's pre-flight check).
func WithImage(image string) Option {
return func(s *Service) {
s.image = strings.TrimSpace(image)
}
}

// NewService returns a local-mode environment service.
func NewService(deps cliruntime.Deps, opts ...Option) Service {
healthClient := deps.HTTPClient
Expand Down Expand Up @@ -157,11 +168,24 @@ func (s Service) Start(ctx context.Context, w io.Writer) error {
}
output.Success(w, "Docker is available")

// When the image is an explicit override (--image / VOLCANO_IMAGE / .env.local)
// it must already exist locally before we announce or start it. Fail fast here
// (Restart calls this before tearing the environment down) instead of letting
// `docker compose up` emit a confusing registry-pull error.
if err := s.ensureCustomImageAvailable(ctx); err != nil {
return err
}

composeEnv, image, err := s.composeEnvironment()
if err != nil {
return err
}
_, customImage := s.resolveImage()

fmt.Fprintf(w, "Using Docker image: %s\n", image)
if customImage {
output.Success(w, "Using local image %q (not pulled)", image)
}

if err := s.startDockerServices(ctx, composeEnv); err != nil {
return fmt.Errorf("failed to start Docker services: %w", err)
Expand Down Expand Up @@ -250,6 +274,11 @@ func (s Service) Stop(ctx context.Context, w io.Writer, clean bool) error {

// Restart restarts the local Volcano Docker stack while preserving data.
func (s Service) Restart(ctx context.Context, w io.Writer) error {
// Validate a custom image before tearing the environment down, so a bad
// --image leaves the running stack intact instead of stopped.
if err := s.ensureCustomImageAvailable(ctx); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: this preflight runs before Start's checkDocker, so restart --image <custom> with the Docker daemon down fails with image "…" not found locally … build it … rather than a "Docker not available" message (docker image inspect errors → imageExistsLocally returns false). Low impact — building the image needs Docker anyway — but slightly misleading. A checkDocker here first would give the clearer message.

return err
}
if err := s.Stop(ctx, w, false); err != nil {
return err
}
Expand Down
93 changes: 93 additions & 0 deletions internal/localmode/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,96 @@ func lastEnvValue(env []string, key string) (string, bool) {
}
return "", false
}

func TestResolveImagePrecedence(t *testing.T) {
setLocalDevTestHome(t)
withTempWorkingDir(t)

emptyEnv := func(string) string { return "" }
newSvc := func(image string, getenv func(string) string) Service {
return NewService(cliruntime.Deps{},
WithImage(image),
WithEnvironment(func() []string { return []string{"PATH=/bin"} }, getenv),
)
}
envWith := func(value string) func(string) string {
return func(k string) string {
if k == "VOLCANO_IMAGE" {
return value
}
return ""
}
}

t.Run("default when nothing set", func(t *testing.T) {
_ = os.Remove(".env.local")
img, overridden := newSvc("", emptyEnv).resolveImage()
assert.Equal(t, defaultVolcanoImage, img)
assert.False(t, overridden)
})

t.Run("env overrides default", func(t *testing.T) {
_ = os.Remove(".env.local")
img, overridden := newSvc("", envWith("kong/volcano:from-env")).resolveImage()
assert.Equal(t, "kong/volcano:from-env", img)
assert.True(t, overridden)
})

t.Run(".env.local used when env unset", func(t *testing.T) {
require.NoError(t, os.WriteFile(".env.local", []byte("VOLCANO_IMAGE=kong/volcano:from-file\n"), 0o600))
img, overridden := newSvc("", emptyEnv).resolveImage()
assert.Equal(t, "kong/volcano:from-file", img)
assert.True(t, overridden)
})

t.Run("flag beats env and .env.local", func(t *testing.T) {
require.NoError(t, os.WriteFile(".env.local", []byte("VOLCANO_IMAGE=kong/volcano:from-file\n"), 0o600))
img, overridden := newSvc("kong/volcano:from-flag", envWith("kong/volcano:from-env")).resolveImage()
assert.Equal(t, "kong/volcano:from-flag", img)
assert.True(t, overridden)
})

t.Run("explicit default value is not treated as custom", func(t *testing.T) {
_ = os.Remove(".env.local")
img, overridden := newSvc(defaultVolcanoImage, emptyEnv).resolveImage()
assert.Equal(t, defaultVolcanoImage, img)
assert.False(t, overridden)
})
}

func TestStartFailsWhenCustomImageMissing(t *testing.T) {
setLocalDevTestHome(t)
withTempWorkingDir(t)

runner := &fakeCommandRunner{
run: func(_ context.Context, command Command) ([]byte, error) {
switch {
case commandIs(command, "docker", "inspect", "--format={{.State.Running}}", serverContainerName):
return []byte("false\n"), nil
case commandIs(command, "docker", "version"):
return []byte("Docker version 1\n"), nil
case commandIs(command, "docker", "image", "inspect", "kong/volcano:local-dev"):
return nil, errors.New("Error: No such image: kong/volcano:local-dev")
case command.Name == "docker" && slices.Contains(command.Args, "up"):
t.Fatalf("compose up must not run when the custom image is missing")
return nil, nil
default:
return nil, nil
}
},
}

var out bytes.Buffer
service := NewService(
cliruntime.Deps{},
WithDockerRunner(runner),
WithImage("kong/volcano:local-dev"),
WithEnvironment(func() []string { return []string{"PATH=/bin"} }, func(string) string { return "" }),
WithTempDir(t.TempDir()),
)

err := service.Start(context.Background(), &out)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found locally")
assert.False(t, runner.calledWithArg("docker", "up"))
}
Loading
Loading