guest: debian: Implement storage_balloon_agent

Add storage_balloon_agent daemon in Debian and its client in
the TerminalApp for enabling storage ballooning.
This feature is hidden behind a feature flag 'terminalStorageBalloon'.

Since we still use non-sparse disks, the balloon shouldn't affect
the guest's disk space yet.

Bug: 382174138
Test: Run a VM and check logs

Change-Id: I75d926bb8aa8a02bf635e94e35715e5aa23c8090
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index 2bac412..e1e236a 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -15,6 +15,7 @@
         "android.system.virtualizationservice_internal-java",
         "androidx-constraintlayout_constraintlayout",
         "androidx.window_window",
+        "androidx.work_work-runtime",
         "apache-commons-compress",
         "avf_aconfig_flags_java",
         "com.google.android.material_material",
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.kt b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.kt
index e035ad4..e81be7f 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.kt
@@ -18,7 +18,8 @@
 import android.content.Context
 import android.util.Log
 import androidx.annotation.Keep
-import com.android.virtualization.terminal.DebianServiceImpl.ForwarderHostCallback
+import com.android.internal.annotations.GuardedBy
+import com.android.system.virtualmachine.flags.Flags
 import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import com.android.virtualization.terminal.PortsStateManager.Companion.getInstance
 import com.android.virtualization.terminal.proto.DebianServiceGrpc.DebianServiceImplBase
@@ -28,6 +29,8 @@
 import com.android.virtualization.terminal.proto.ReportVmActivePortsResponse
 import com.android.virtualization.terminal.proto.ShutdownQueueOpeningRequest
 import com.android.virtualization.terminal.proto.ShutdownRequestItem
+import com.android.virtualization.terminal.proto.StorageBalloonQueueOpeningRequest
+import com.android.virtualization.terminal.proto.StorageBalloonRequestItem
 import io.grpc.stub.ServerCallStreamObserver
 import io.grpc.stub.StreamObserver
 
@@ -35,6 +38,8 @@
     private val portsStateManager: PortsStateManager = getInstance(context)
     private var portsStateListener: PortsStateManager.Listener? = null
     private var shutdownRunnable: Runnable? = null
+    private val mLock = Object()
+    @GuardedBy("mLock") private var storageBalloonCallback: StorageBalloonCallback? = null
 
     override fun reportVmActivePorts(
         request: ReportVmActivePortsRequest,
@@ -80,10 +85,9 @@
         request: ShutdownQueueOpeningRequest?,
         responseObserver: StreamObserver<ShutdownRequestItem?>,
     ) {
-        val serverCallStreamObserver = responseObserver as ServerCallStreamObserver<ShutdownRequestItem?>
-        serverCallStreamObserver.setOnCancelHandler {
-            shutdownRunnable = null
-        }
+        val serverCallStreamObserver =
+            responseObserver as ServerCallStreamObserver<ShutdownRequestItem?>
+        serverCallStreamObserver.setOnCancelHandler { shutdownRunnable = null }
         Log.d(TAG, "openShutdownRequestQueue")
         shutdownRunnable = Runnable {
             if (serverCallStreamObserver.isCancelled()) {
@@ -95,6 +99,60 @@
         }
     }
 
+    private class StorageBalloonCallback(
+        private val responseObserver: StreamObserver<StorageBalloonRequestItem?>
+    ) {
+        fun setAvailableStorageBytes(availableBytes: Long) {
+            Log.d(TAG, "send setStorageBalloon: $availableBytes")
+            val item =
+                StorageBalloonRequestItem.newBuilder().setAvailableBytes(availableBytes).build()
+            responseObserver.onNext(item)
+        }
+
+        fun closeConnection() {
+            Log.d(TAG, "close StorageBalloonQueue")
+            responseObserver.onCompleted()
+        }
+    }
+
+    fun setAvailableStorageBytes(availableBytes: Long): Boolean {
+        synchronized(mLock) {
+            if (storageBalloonCallback == null) {
+                Log.d(TAG, "storageBalloonCallback is not ready.")
+                return false
+            }
+            storageBalloonCallback!!.setAvailableStorageBytes(availableBytes)
+        }
+        return true
+    }
+
+    override fun openStorageBalloonRequestQueue(
+        request: StorageBalloonQueueOpeningRequest?,
+        responseObserver: StreamObserver<StorageBalloonRequestItem?>,
+    ) {
+        if (!Flags.terminalStorageBalloon()) {
+            return
+        }
+        Log.d(TAG, "openStorageRequestQueue")
+        synchronized(mLock) {
+            if (storageBalloonCallback != null) {
+                Log.d(TAG, "RequestQueue already exists. Closing connection.")
+                storageBalloonCallback!!.closeConnection()
+            }
+            storageBalloonCallback = StorageBalloonCallback(responseObserver)
+        }
+    }
+
+    fun closeStorageBalloonRequestQueue() {
+        Log.d(TAG, "Stopping storage balloon queue")
+        synchronized(mLock) {
+            if (storageBalloonCallback != null) {
+                storageBalloonCallback!!.closeConnection()
+                storageBalloonCallback = null
+            }
+        }
+    }
+
     @Keep
     private class ForwarderHostCallback(
         private val responseObserver: StreamObserver<ForwardingRequestItem?>
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/StorageBalloonWorker.kt b/android/TerminalApp/java/com/android/virtualization/terminal/StorageBalloonWorker.kt
new file mode 100644
index 0000000..345bf75
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/StorageBalloonWorker.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * 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 com.android.virtualization.terminal
+
+import android.content.Context
+import android.os.storage.StorageManager
+import android.os.storage.StorageManager.UUID_DEFAULT
+import android.util.Log
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
+import java.util.concurrent.TimeUnit
+
+class StorageBalloonWorker(appContext: Context, workerParams: WorkerParameters) :
+    Worker(appContext, workerParams) {
+
+    override fun doWork(): Result {
+        Log.d(TAG, "StorageBalloonWorker.doWork() called")
+
+        var storageManager =
+            applicationContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager
+        val hostAllocatableBytes = storageManager.getAllocatableBytes(UUID_DEFAULT)
+
+        val guestAvailableBytes = calculateGuestAvailableStorageSize(hostAllocatableBytes)
+        // debianService must be set when this function is called.
+        debianService!!.setAvailableStorageBytes(guestAvailableBytes)
+
+        val delaySeconds = calculateDelaySeconds(hostAllocatableBytes)
+        scheduleNextTask(delaySeconds)
+
+        return Result.success()
+    }
+
+    private fun calculateGuestAvailableStorageSize(hostAllocatableBytes: Long): Long {
+        return hostAllocatableBytes - HOST_RESERVED_BYTES
+    }
+
+    private fun calculateDelaySeconds(hostAvailableBytes: Long): Long {
+        return when {
+            hostAvailableBytes < CRITICAL_STORAGE_THRESHOLD_BYTES -> CRITICAL_DELAY_SECONDS
+            hostAvailableBytes < LOW_STORAGE_THRESHOLD_BYTES -> LOW_STORAGE_DELAY_SECONDS
+            hostAvailableBytes < MODERATE_STORAGE_THRESHOLD_BYTES -> MODERATE_STORAGE_DELAY_SECONDS
+            else -> NORMAL_DELAY_SECONDS
+        }
+    }
+
+    private fun scheduleNextTask(delaySeconds: Long) {
+        val storageBalloonTaskRequest =
+            androidx.work.OneTimeWorkRequest.Builder(StorageBalloonWorker::class.java)
+                .setInitialDelay(delaySeconds, TimeUnit.SECONDS)
+                .build()
+        androidx.work.WorkManager.getInstance(applicationContext)
+            .enqueueUniqueWork(
+                "storageBalloonTask",
+                androidx.work.ExistingWorkPolicy.REPLACE,
+                storageBalloonTaskRequest,
+            )
+        Log.d(TAG, "next storage balloon task is scheduled in $delaySeconds seconds")
+    }
+
+    companion object {
+        private var debianService: DebianServiceImpl? = null
+
+        // Reserve 1GB as host-only region.
+        private const val HOST_RESERVED_BYTES = 1024L * 1024 * 1024
+
+        // Thresholds for deciding time period to report storage information to the guest.
+        // Less storage is available on the host, more frequently the host will report storage
+        // information to the guest.
+        //
+        // Critical: (host storage < 1GB) => report every 5 seconds
+        private const val CRITICAL_STORAGE_THRESHOLD_BYTES = 1L * 1024 * 1024 * 1024
+        private const val CRITICAL_DELAY_SECONDS = 5L
+        // Low: (1GB <= storage < 5GB) => report every 60 seconds
+        private const val LOW_STORAGE_THRESHOLD_BYTES = 5L * 1024 * 1024 * 1024
+        private const val LOW_STORAGE_DELAY_SECONDS = 60L
+        // Moderate: (5GB <= storage < 10GB) => report every 15 minutes
+        private const val MODERATE_STORAGE_THRESHOLD_BYTES = 10L * 1024 * 1024 * 1024
+        private const val MODERATE_STORAGE_DELAY_SECONDS = 15L * 60
+        // Normal: report every 60 minutes
+        private const val NORMAL_DELAY_SECONDS = 60L * 60
+
+        internal fun start(ctx: Context, ds: DebianServiceImpl) {
+            debianService = ds
+            val storageBalloonTaskRequest =
+                androidx.work.OneTimeWorkRequest.Builder(StorageBalloonWorker::class.java)
+                    .setInitialDelay(1, TimeUnit.SECONDS)
+                    .build()
+            androidx.work.WorkManager.getInstance(ctx)
+                .enqueueUniqueWork(
+                    "storageBalloonTask",
+                    androidx.work.ExistingWorkPolicy.REPLACE,
+                    storageBalloonTaskRequest,
+                )
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 1857175..0a1f0ee 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -38,10 +38,9 @@
 import android.system.virtualmachine.VirtualMachineException
 import android.util.Log
 import android.widget.Toast
-import com.android.system.virtualmachine.flags.Flags.terminalGuiSupport
+import com.android.system.virtualmachine.flags.Flags
 import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import com.android.virtualization.terminal.Runner.Companion.create
-import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
 import io.grpc.Grpc
 import io.grpc.InsecureServerCredentials
 import io.grpc.Metadata
@@ -54,7 +53,6 @@
 import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
-import java.lang.RuntimeException
 import java.net.InetSocketAddress
 import java.net.SocketAddress
 import java.nio.file.Files
@@ -285,7 +283,7 @@
 
         // Set the initial display size
         // TODO(jeongik): set up the display size on demand
-        if (terminalGuiSupport() && displayInfo != null) {
+        if (Flags.terminalGuiSupport() && displayInfo != null) {
             builder
                 .setDisplayConfig(
                     VirtualMachineCustomImageConfig.DisplayConfig.Builder()
@@ -360,6 +358,10 @@
                 }
             }
         )
+
+        if (Flags.terminalStorageBalloon()) {
+            StorageBalloonWorker.start(this, debianService!!)
+        }
     }
 
     override fun onDestroy() {
@@ -383,6 +385,7 @@
 
     private fun stopDebianServer() {
         debianService?.killForwarderHost()
+        debianService?.closeStorageBalloonRequestQueue()
         server?.shutdown()
     }
 
diff --git a/build/avf_flags.aconfig b/build/avf_flags.aconfig
index 921c374..571c359 100644
--- a/build/avf_flags.aconfig
+++ b/build/avf_flags.aconfig
@@ -16,4 +16,12 @@
   namespace: "virtualization"
   description: "Flag for GUI support in terminal"
   bug: "386296118"
+}
+
+flag {
+  name: "terminal_storage_balloon"
+  is_exported: true
+  namespace: "virtualization"
+  description: "Flag for storage ballooning support in terminal"
+  bug: "382174138"
 }
\ No newline at end of file
diff --git a/build/debian/build.sh b/build/debian/build.sh
index 9c4d4b1..8c1345c 100755
--- a/build/debian/build.sh
+++ b/build/debian/build.sh
@@ -204,6 +204,7 @@
 	build_rust_as_deb forwarder_guest
 	build_rust_as_deb forwarder_guest_launcher
 	build_rust_as_deb shutdown_runner
+	build_rust_as_deb storage_balloon_agent
 }
 
 package_custom_kernel() {
diff --git a/build/debian/fai_config/package_config/AVF b/build/debian/fai_config/package_config/AVF
index 3aa8ab0..f1ee065 100644
--- a/build/debian/fai_config/package_config/AVF
+++ b/build/debian/fai_config/package_config/AVF
@@ -8,6 +8,7 @@
 forwarder-guest
 forwarder-guest-launcher
 shutdown-runner
+storage-balloon-agent
 weston
 xwayland
 mesa-vulkan-drivers
diff --git a/guest/storage_balloon_agent/.cargo/config.toml b/guest/storage_balloon_agent/.cargo/config.toml
new file mode 100644
index 0000000..a451cda
--- /dev/null
+++ b/guest/storage_balloon_agent/.cargo/config.toml
@@ -0,0 +1,6 @@
+[target.aarch64-unknown-linux-gnu]
+linker = "aarch64-linux-gnu-gcc"
+rustflags = ["-C", "target-feature=+crt-static"]
+
+[target.x86_64-unknown-linux-gnu]
+rustflags = ["-C", "target-feature=+crt-static"]
diff --git a/guest/storage_balloon_agent/.gitignore b/guest/storage_balloon_agent/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/guest/storage_balloon_agent/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/guest/storage_balloon_agent/Cargo.toml b/guest/storage_balloon_agent/Cargo.toml
new file mode 100644
index 0000000..ce0e5d7
--- /dev/null
+++ b/guest/storage_balloon_agent/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "storage_balloon_agent"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+
+[dependencies]
+anyhow = "1.0.94"
+clap = { version = "4.5.20", features = ["derive"] }
+env_logger = "0.10.2"
+log = "0.4.22"
+netdev = "0.31.0"
+nix = { version = "0.28.0", features = ["fs"] }
+prost = "0.13.3"
+tokio = { version = "1.40.0", features = ["rt-multi-thread"] }
+tonic = "0.12.3"
+
+[build-dependencies]
+tonic-build = "0.12.3"
+
+[package.metadata.deb]
+maintainer = "ferrochrome-dev@google.com"
+copyright = "2025, The Android Open Source Project"
+depends = "$auto"
+maintainer-scripts = "debian/"
+systemd-units = { }
diff --git a/guest/storage_balloon_agent/build.rs b/guest/storage_balloon_agent/build.rs
new file mode 100644
index 0000000..e3939d4
--- /dev/null
+++ b/guest/storage_balloon_agent/build.rs
@@ -0,0 +1,7 @@
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let proto_file = "../../libs/debian_service/proto/DebianService.proto";
+
+    tonic_build::compile_protos(proto_file).unwrap();
+
+    Ok(())
+}
diff --git a/guest/storage_balloon_agent/debian/service b/guest/storage_balloon_agent/debian/service
new file mode 100644
index 0000000..0e9b03a
--- /dev/null
+++ b/guest/storage_balloon_agent/debian/service
@@ -0,0 +1,17 @@
+[Unit]
+After=syslog.target
+After=network.target
+After=virtiofs_internal.service
+
+[Service]
+ExecStart=/usr/bin/bash -c '/usr/bin/storage_balloon_agent --grpc_port_file /mnt/internal/debian_service_port'
+Type=simple
+Restart=on-failure
+RestartSec=1
+User=root
+Group=root
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
diff --git a/guest/storage_balloon_agent/rustfmt.toml b/guest/storage_balloon_agent/rustfmt.toml
new file mode 120000
index 0000000..be3dbe2
--- /dev/null
+++ b/guest/storage_balloon_agent/rustfmt.toml
@@ -0,0 +1 @@
+../../../../../build/soong/scripts/rustfmt.toml
\ No newline at end of file
diff --git a/guest/storage_balloon_agent/src/main.rs b/guest/storage_balloon_agent/src/main.rs
new file mode 100644
index 0000000..817b337
--- /dev/null
+++ b/guest/storage_balloon_agent/src/main.rs
@@ -0,0 +1,141 @@
+// Copyright 2025 The Android Open Source Project
+//
+// 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.
+
+//! gRPC daemon for the storage ballooning feature.
+
+use anyhow::anyhow;
+use anyhow::Context;
+use anyhow::Result;
+use api::debian_service_client::DebianServiceClient;
+use api::StorageBalloonQueueOpeningRequest;
+use api::StorageBalloonRequestItem;
+use clap::Parser;
+use log::debug;
+use log::error;
+use log::info;
+use nix::sys::statvfs::statvfs;
+pub mod api {
+    tonic::include_proto!("com.android.virtualization.terminal.proto");
+}
+
+#[derive(Parser)]
+/// Flags for running command
+pub struct Args {
+    /// IP address
+    #[arg(long)]
+    addr: Option<String>,
+
+    /// path to a file where grpc port number is written
+    #[arg(long)]
+    #[arg(alias = "grpc_port_file")]
+    grpc_port_file: String,
+}
+
+// Calculates how many blocks to be reserved.
+fn calculate_clusters_count(guest_available_bytes: u64) -> Result<u64> {
+    let stat = statvfs("/").context("failed to get statvfs")?;
+    let fr_size = stat.fragment_size() as u64;
+
+    if fr_size == 0 {
+        return Err(anyhow::anyhow!("fragment size is zero, fr_size: {}", fr_size));
+    }
+
+    let total = fr_size.checked_mul(stat.blocks() as u64).context(format!(
+        "overflow in total size calculation, fr_size: {}, blocks: {}",
+        fr_size,
+        stat.blocks()
+    ))?;
+
+    let free = fr_size.checked_mul(stat.blocks_available() as u64).context(format!(
+        "overflow in free size calculation, fr_size: {}, blocks_available: {}",
+        fr_size,
+        stat.blocks_available()
+    ))?;
+
+    let used = total
+        .checked_sub(free)
+        .context(format!("underflow in used size calculation (free > total), which should not happen, total: {}, free: {}", total, free))?;
+
+    let avail = std::cmp::min(free, guest_available_bytes);
+    let balloon_size_bytes = free - avail;
+
+    let reserved_clusters_count = balloon_size_bytes.div_ceil(fr_size);
+
+    debug!("total: {total}, free: {free}, used: {used}, avail: {avail}, balloon: {balloon_size_bytes}, clusters_count: {reserved_clusters_count}");
+
+    Ok(reserved_clusters_count)
+}
+
+fn set_reserved_clusters(clusters_count: u64) -> anyhow::Result<()> {
+    const ROOTFS_DEVICE_NAME: &str = "vda1";
+    std::fs::write(
+        format!("/sys/fs/ext4/{ROOTFS_DEVICE_NAME}/reserved_clusters"),
+        clusters_count.to_string(),
+    )
+    .context("failed to write reserved_clusters")?;
+    Ok(())
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    env_logger::builder().filter_level(log::LevelFilter::Debug).init();
+
+    let args = Args::parse();
+    let gateway_ip_addr = netdev::get_default_gateway()?.ipv4[0];
+    let addr = args.addr.unwrap_or_else(|| gateway_ip_addr.to_string());
+
+    // Wait for `grpc_port_file` becomes available.
+    const GRPC_PORT_MAX_RETRY_COUNT: u32 = 10;
+    for _ in 0..GRPC_PORT_MAX_RETRY_COUNT {
+        if std::path::Path::new(&args.grpc_port_file).exists() {
+            break;
+        }
+        debug!("{} does not exist. Wait 1 second", args.grpc_port_file);
+        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+    }
+    let grpc_port = std::fs::read_to_string(&args.grpc_port_file)?.trim().to_string();
+    let server_addr = format!("http://{}:{}", addr, grpc_port);
+
+    info!("connect to grpc server {}", server_addr);
+    let mut client = DebianServiceClient::connect(server_addr)
+        .await
+        .map_err(|e| anyhow!("failed to connect to grpc server: {:#}", e))?;
+    info!("connection established");
+
+    let mut res_stream = client
+        .open_storage_balloon_request_queue(tonic::Request::new(
+            StorageBalloonQueueOpeningRequest {},
+        ))
+        .await
+        .map_err(|e| anyhow!("failed to open storage balloon queue: {:#}", e))?
+        .into_inner();
+
+    while let Some(StorageBalloonRequestItem { available_bytes }) =
+        res_stream.message().await.map_err(|e| anyhow!("failed to receive message: {:#}", e))?
+    {
+        let clusters_count = match calculate_clusters_count(available_bytes) {
+            Ok(c) => c,
+            Err(e) => {
+                error!("failed to calculate cluster size to be reserved: {:#}", e);
+                continue;
+            }
+        };
+
+        if let Err(e) = set_reserved_clusters(clusters_count) {
+            error!("failed to set storage balloon size: {}", e);
+        }
+    }
+
+    Ok(())
+}
diff --git a/libs/debian_service/proto/DebianService.proto b/libs/debian_service/proto/DebianService.proto
index 43955fa..e52b28a 100644
--- a/libs/debian_service/proto/DebianService.proto
+++ b/libs/debian_service/proto/DebianService.proto
@@ -25,6 +25,7 @@
   rpc ReportVmActivePorts (ReportVmActivePortsRequest) returns (ReportVmActivePortsResponse) {}
   rpc OpenForwardingRequestQueue (QueueOpeningRequest) returns (stream ForwardingRequestItem) {}
   rpc OpenShutdownRequestQueue (ShutdownQueueOpeningRequest) returns (stream ShutdownRequestItem) {}
+  rpc OpenStorageBalloonRequestQueue (StorageBalloonQueueOpeningRequest) returns (stream StorageBalloonRequestItem) {}
 }
 
 message QueueOpeningRequest {
@@ -52,3 +53,9 @@
 message ShutdownQueueOpeningRequest {}
 
 message ShutdownRequestItem {}
+
+message StorageBalloonQueueOpeningRequest {}
+
+message StorageBalloonRequestItem {
+  uint64 available_bytes = 1;
+}