Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 12 additions & 0 deletions pkg/reconciler/wasmmodule/testdata/wasi_config.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"args": ["--verbose"],
"env": {"GREETING": "hello", "PORT": "8080"},
"dirs": [
{"hostPath": "/mnt/data", "guestPath": "/mnt/data", "readOnly": false},
{"hostPath": "/mnt/ro", "guestPath": "/mnt/ro", "readOnly": true}
],
"network": {
"allowIpNameLookup": true,
"tcpConnect": ["example.com:443"]
}
}
91 changes: 91 additions & 0 deletions pkg/reconciler/wasmmodule/wasmmodule_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
Copyright 2026 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package wasmmodule

import (
"encoding/json"
"os"
"testing"

api "github.com/cardil/knative-serving-wasm/pkg/apis/wasm/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// TestBuildRunnerConfigMatchesGolden verifies that the JSON produced by
// buildRunnerConfig matches the documented wire format that the runner parses.
// If this test fails, both the golden file and the runner must be updated together.
func TestBuildRunnerConfigMatchesGolden(t *testing.T) {
trueVal := true
module := &api.WasmModule{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Spec: api.WasmModuleSpec{
Image: "example.com/img:latest",
Args: []string{"--verbose"},
Env: []corev1.EnvVar{
{Name: "GREETING", Value: "hello"},
{Name: "PORT", Value: "8080"},
},
VolumeMounts: []corev1.VolumeMount{
{Name: "data", MountPath: "/mnt/data", ReadOnly: false},
{Name: "ro", MountPath: "/mnt/ro", ReadOnly: true},
},
Volumes: []corev1.Volume{
{Name: "data"},
{Name: "ro"},
},
Network: &api.NetworkSpec{
Inherit: false,
AllowIpNameLookup: &trueVal,
Tcp: &api.TcpSpec{
Connect: []string{"example.com:443"},
},
},
},
}

got, err := buildRunnerConfig(module)
if err != nil {
t.Fatalf("buildRunnerConfig() error: %v", err)
}

goldenBytes, err := os.ReadFile("testdata/wasi_config.golden.json")
if err != nil {
t.Fatalf("read golden file: %v", err)
}

var gotMap, wantMap any
if err := json.Unmarshal([]byte(got), &gotMap); err != nil {
t.Fatalf("unmarshal got: %v", err)
}
if err := json.Unmarshal(goldenBytes, &wantMap); err != nil {
t.Fatalf("unmarshal golden: %v", err)
}

gotJSON, err := json.MarshalIndent(gotMap, "", " ")
if err != nil {
t.Fatalf("marshal got normalized JSON: %v", err)
}
wantJSON, err := json.MarshalIndent(wantMap, "", " ")
if err != nil {
t.Fatalf("marshal golden normalized JSON: %v", err)
}

if string(gotJSON) != string(wantJSON) {
t.Errorf("buildRunnerConfig output does not match golden.\ngot:\n%s\n\nwant:\n%s", gotJSON, wantJSON)
}
}
77 changes: 54 additions & 23 deletions runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,45 +23,37 @@ pub struct WasiConfig {
/// OCI image containing the WASM module
#[serde(default)]
pub image: String,

/// Command line arguments to pass to the WASM module
#[serde(default)]
pub args: Vec<String>,

/// Environment variables to set in the WASM module

/// Environment variables to set in the WASM module.
/// Serialized as a JSON object (map) by the controller: {"KEY": "VALUE"}.
#[serde(default)]
pub env: Vec<EnvVar>,

/// Volume mounts to expose as WASI preopened directories
pub env: HashMap<String, String>,

/// Directory mounts to expose as WASI preopened directories.
/// Serialized as "dirs" by the controller.
#[serde(default)]
pub volume_mounts: Vec<VolumeMount>,
pub dirs: Vec<DirConfig>,

/// Resource requirements (memory, CPU limits)
#[serde(default)]
pub resources: ResourceRequirements,

/// Network access configuration
pub network: Option<NetworkSpec>,
}

/// Environment variable configuration
#[derive(Debug, Deserialize, Clone)]
pub struct EnvVar {
pub name: String,
#[serde(default)]
pub value: String,
}

/// Volume mount configuration
/// Directory mount configuration as produced by the controller.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VolumeMount {
pub name: String,
pub mount_path: String,
pub struct DirConfig {
pub host_path: String,
pub guest_path: String,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub sub_path: String,
}

/// Resource requirements for the WASM module
Expand Down Expand Up @@ -137,3 +129,42 @@ impl WasiConfig {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Contract test: the runner must parse the exact JSON produced by the controller.
/// The golden file is the single source of truth for the wire format.
/// Both sides must agree: update the golden file and this test together.
#[test]
fn test_parse_golden_wasi_config() {
let golden = std::fs::read_to_string(
"../pkg/reconciler/wasmmodule/testdata/wasi_config.golden.json",
)
.expect("golden file must exist");

let config: WasiConfig = serde_json::from_str(&golden)
.expect("golden JSON must parse into WasiConfig");

// env is a map in the wire format
assert_eq!(config.env.get("GREETING"), Some(&"hello".to_string()));
assert_eq!(config.env.get("PORT"), Some(&"8080".to_string()));

// dirs (not volumeMounts) with hostPath/guestPath
assert_eq!(config.dirs.len(), 2);
assert_eq!(config.dirs[0].host_path, "/mnt/data");
assert_eq!(config.dirs[0].guest_path, "/mnt/data");
assert!(!config.dirs[0].read_only);
assert_eq!(config.dirs[1].host_path, "/mnt/ro");
assert!(config.dirs[1].read_only);

// args
assert_eq!(config.args, vec!["--verbose"]);

// network
let net = config.network.as_ref().expect("network must be present");
assert!(net.allow_ip_name_lookup);
assert_eq!(net.tcp_connect, vec!["example.com:443"]);
}
}
2 changes: 1 addition & 1 deletion runner/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async fn main() -> Result<()> {
println!(" Image: {}", wasi_config.image);
println!(" Args: {:?}", wasi_config.args);
println!(" Env vars: {} entries", wasi_config.env.len());
println!(" Volume mounts: {} entries", wasi_config.volume_mounts.len());
println!(" Dirs: {} entries", wasi_config.dirs.len());
if let Some(memory) = wasi_config.resources.get_memory() {
println!(" Memory: {}", memory);
}
Expand Down
31 changes: 12 additions & 19 deletions runner/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,35 +168,28 @@ fn build_wasi_ctx(config: &WasiConfig) -> Result<WasiCtx> {
}

// Add environment variables
for env_var in &config.env {
builder.env(&env_var.name, &env_var.value);
for (key, val) in &config.env {
builder.env(key, val);
}
// Add preopened directories from volume mounts
for mount in &config.volume_mounts {

// Add preopened directories
for dir in &config.dirs {
use std::path::PathBuf;
use wasmtime_wasi::{DirPerms, FilePerms};

// Build the host path, applying subPath if specified
let host_path: PathBuf = if mount.sub_path.is_empty() {
PathBuf::from(&mount.mount_path)
} else {
PathBuf::from(&mount.mount_path).join(&mount.sub_path)
};

let guest_path = &mount.mount_path;

let (dir_perms, file_perms) = if mount.read_only {

let host_path = PathBuf::from(&dir.host_path);
let guest_path = &dir.guest_path;

let (dir_perms, file_perms) = if dir.read_only {
(DirPerms::READ, FilePerms::READ)
} else {
(DirPerms::all(), FilePerms::all())
};

// Fail fast if the directory doesn't exist
if !host_path.exists() {
return Err(anyhow::anyhow!(
"Volume mount '{}' path does not exist: {}",
mount.name,
"Dir mount host path does not exist: {}",
host_path.display()
));
}
Expand Down
Loading