This commit is contained in:
Aiden McClelland
2021-07-12 14:44:03 -06:00
parent a2188be4cd
commit d26e08014f
20 changed files with 3283 additions and 30 deletions

1
appmgr/.gitignore vendored
View File

@@ -3,3 +3,4 @@
.DS_Store .DS_Store
.vscode .vscode
secrets.db secrets.db
*.s9pk

22
appmgr/Cargo.lock generated
View File

@@ -710,6 +710,7 @@ dependencies = [
"sha2 0.9.5", "sha2 0.9.5",
"simple-logging", "simple-logging",
"sqlx", "sqlx",
"tar",
"thiserror", "thiserror",
"tokio 1.9.0", "tokio 1.9.0",
"tokio-compat-02", "tokio-compat-02",
@@ -1563,6 +1564,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]]
name = "openssl-src"
version = "111.15.0+1.1.1k"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1a5f6ae2ac04393b217ea9f700cd04fa9bf3d93fae2872069f3d15d908af70a"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.65" version = "0.9.65"
@@ -1572,6 +1582,7 @@ dependencies = [
"autocfg", "autocfg",
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@@ -2538,6 +2549,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d779dc6aeff029314570f666ec83f19df7280bb36ef338442cfa8c604021b80"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.2.0" version = "3.2.0"

View File

@@ -64,7 +64,7 @@ lazy_static = "1.4"
libc = "0.2.86" libc = "0.2.86"
log = "0.4.11" log = "0.4.11"
nix = "0.20.0" nix = "0.20.0"
openssl = "0.10.30" openssl = { version="0.10.30", features=["vendored"] }
patch-db = { version="*", path="../../patch-db/patch-db" } patch-db = { version="*", path="../../patch-db/patch-db" }
pin-project = "1.0.6" pin-project = "1.0.6"
prettytable-rs = "0.8.0" prettytable-rs = "0.8.0"
@@ -83,6 +83,7 @@ serde_yaml = "0.8.14"
sha2 = "0.9.3" sha2 = "0.9.3"
simple-logging = "2.0" simple-logging = "2.0"
sqlx = { version="0.5", features=["runtime-tokio-rustls", "sqlite", "offline"] } sqlx = { version="0.5", features=["runtime-tokio-rustls", "sqlite", "offline"] }
tar = "0.4.35"
thiserror = "1.0.24" thiserror = "1.0.24"
tokio = { version="1.5.0", features=["full"] } tokio = { version="1.5.0", features=["full"] }
tokio-compat-02 = "0.2.0" tokio-compat-02 = "0.2.0"

Binary file not shown.

View File

@@ -77,6 +77,8 @@ impl DockerAction {
} else { } else {
None None
}; };
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut handle = cmd.spawn().with_kind(crate::ErrorKind::Docker)?; let mut handle = cmd.spawn().with_kind(crate::ErrorKind::Docker)?;
if let (Some(input), Some(stdin)) = (&input_buf, &mut handle.stdin) { if let (Some(input), Some(stdin)) = (&input_buf, &mut handle.stdin) {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -133,6 +135,8 @@ impl DockerAction {
} else { } else {
None None
}; };
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut handle = cmd.spawn().with_kind(crate::ErrorKind::Docker)?; let mut handle = cmd.spawn().with_kind(crate::ErrorKind::Docker)?;
if let (Some(input), Some(stdin)) = (&input_buf, &mut handle.stdin) { if let (Some(input), Some(stdin)) = (&input_buf, &mut handle.stdin) {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -177,9 +181,11 @@ impl DockerAction {
format!("service_{}_{}", pkg_id, version) format!("service_{}_{}", pkg_id, version)
} }
pub fn uncontainer_name(name: &str) -> Option<&str> { pub fn uncontainer_name(name: &str) -> Option<(&str, Version)> {
name.strip_prefix("service_") name.trim_start_matches("/")
.and_then(|name| name.split("_").next()) .strip_prefix("service_")
.and_then(|name| name.split_once("_"))
.and_then(|(id, version)| Some((id, version.parse().ok()?)))
} }
fn docker_args<'a>( fn docker_args<'a>(
@@ -196,18 +202,20 @@ impl DockerAction {
+ self.args.len(), // [ARG...] + self.args.len(), // [ARG...]
); );
for (volume_id, dst) in &self.mounts { for (volume_id, dst) in &self.mounts {
let src = if let Some(path) = volumes.get_path_for(pkg_id, volume_id) { let volume = if let Some(v) = volumes.get(volume_id) {
path v
} else { } else {
continue; continue;
}; };
let src = volume.path_for(pkg_id, pkg_version, volume_id);
res.push(OsStr::new("--mount").into()); res.push(OsStr::new("--mount").into());
res.push( res.push(
OsString::from(format!( dbg!(OsString::from(format!(
"type=bind,src={},dst={}", "type=bind,src={},dst={}{}",
src.display(), src.display(),
dst.display() dst.display(),
)) if volume.readonly() { ",readonly" } else { "" }
)))
.into(), .into(),
); );
} }

View File

@@ -7,7 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::util::Version; use crate::util::Version;
use crate::Error; use crate::Error;
pub const SYSTEM_ID: Id<&'static str> = Id("SYSTEM"); pub const SYSTEM_ID: Id<&'static str> = Id("x_system");
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("Invalid ID")] #[error("Invalid ID")]

View File

@@ -379,8 +379,6 @@ pub async fn install_s9pk<R: AsyncRead + AsyncSeek + Unpin>(
let interface_info = manifest.interfaces.install(&mut sql_tx, pkg_id, ip).await?; let interface_info = manifest.interfaces.install(&mut sql_tx, pkg_id, ip).await?;
log::info!("Install {}@{}: Installed interfaces", pkg_id, version); log::info!("Install {}@{}: Installed interfaces", pkg_id, version);
log::info!("Install {}@{}: Complete", pkg_id, version);
let static_files = StaticFiles::local(pkg_id, version, manifest.assets.icon_type()); let static_files = StaticFiles::local(pkg_id, version, manifest.assets.icon_type());
let current_dependencies = manifest let current_dependencies = manifest
.dependencies .dependencies
@@ -489,11 +487,19 @@ pub async fn install_s9pk<R: AsyncRead + AsyncSeek + Unpin>(
} }
} }
log::info!("Install {}@{}: Syncing Tor", pkg_id, version);
ctx.tor_controller.sync(&mut tx, &mut sql_tx).await?; ctx.tor_controller.sync(&mut tx, &mut sql_tx).await?;
log::info!("Install {}@{}: Synced Tor", pkg_id, version);
#[cfg(feature = "avahi")] #[cfg(feature = "avahi")]
{
log::info!("Install {}@{}: Syncing MDNS", pkg_id, version);
ctx.mdns_controller.sync(&mut tx).await?; ctx.mdns_controller.sync(&mut tx).await?;
log::info!("Install {}@{}: Synced MDNS", pkg_id, version);
}
tx.commit(None).await?; tx.commit(None).await?;
log::info!("Install {}@{}: Complete", pkg_id, version);
Ok(()) Ok(())
} }

View File

@@ -18,6 +18,7 @@ pub struct S9pkPacker<
RInstructions: Read, RInstructions: Read,
RIcon: Read, RIcon: Read,
RDockerImages: Read, RDockerImages: Read,
RAssets: Read,
> { > {
writer: W, writer: W,
manifest: &'a Manifest, manifest: &'a Manifest,
@@ -25,6 +26,7 @@ pub struct S9pkPacker<
instructions: RInstructions, instructions: RInstructions,
icon: RIcon, icon: RIcon,
docker_images: RDockerImages, docker_images: RDockerImages,
assets: RAssets,
} }
impl< impl<
'a, 'a,
@@ -33,7 +35,8 @@ impl<
RInstructions: Read, RInstructions: Read,
RIcon: Read, RIcon: Read,
RDockerImages: Read, RDockerImages: Read,
> S9pkPacker<'a, W, RLicense, RInstructions, RIcon, RDockerImages> RAssets: Read,
> S9pkPacker<'a, W, RLicense, RInstructions, RIcon, RDockerImages, RAssets>
{ {
/// BLOCKING /// BLOCKING
pub fn pack(mut self, key: &ed25519_dalek::Keypair) -> Result<(), Error> { pub fn pack(mut self, key: &ed25519_dalek::Keypair) -> Result<(), Error> {
@@ -93,13 +96,22 @@ impl<
position = new_pos; position = new_pos;
// docker_images // docker_images
std::io::copy(&mut self.docker_images, &mut writer) std::io::copy(&mut self.docker_images, &mut writer)
.with_ctx(|_| (crate::ErrorKind::Filesystem, "Copying App Image"))?; .with_ctx(|_| (crate::ErrorKind::Filesystem, "Copying Docker Images"))?;
let new_pos = writer.stream_position()?; let new_pos = writer.stream_position()?;
header.table_of_contents.docker_images = FileSection { header.table_of_contents.docker_images = FileSection {
position, position,
length: new_pos - position, length: new_pos - position,
}; };
position = new_pos; position = new_pos;
// docker_images
std::io::copy(&mut self.assets, &mut writer)
.with_ctx(|_| (crate::ErrorKind::Filesystem, "Copying Assets"))?;
let new_pos = writer.stream_position()?;
header.table_of_contents.assets = FileSection {
position,
length: new_pos - position,
};
position = new_pos;
// header // header
let (hash, _) = writer.finish(); let (hash, _) = writer.finish();

View File

@@ -74,6 +74,7 @@ pub struct TableOfContents {
pub instructions: FileSection, pub instructions: FileSection,
pub icon: FileSection, pub icon: FileSection,
pub docker_images: FileSection, pub docker_images: FileSection,
pub assets: FileSection,
} }
impl TableOfContents { impl TableOfContents {
pub fn serialize<W: Write>(&self, mut writer: W) -> std::io::Result<()> { pub fn serialize<W: Write>(&self, mut writer: W) -> std::io::Result<()> {
@@ -81,7 +82,8 @@ impl TableOfContents {
+ (1 + "license".len() + 16) + (1 + "license".len() + 16)
+ (1 + "instructions".len() + 16) + (1 + "instructions".len() + 16)
+ (1 + "icon".len() + 16) + (1 + "icon".len() + 16)
+ (1 + "docker_images".len() + 16)) as u32; + (1 + "docker_images".len() + 16)
+ (1 + "assets".len() + 16)) as u32;
writer.write_all(&u32::to_be_bytes(len))?; writer.write_all(&u32::to_be_bytes(len))?;
self.manifest.serialize_entry("manifest", &mut writer)?; self.manifest.serialize_entry("manifest", &mut writer)?;
self.license.serialize_entry("license", &mut writer)?; self.license.serialize_entry("license", &mut writer)?;
@@ -90,6 +92,7 @@ impl TableOfContents {
self.icon.serialize_entry("icon", &mut writer)?; self.icon.serialize_entry("icon", &mut writer)?;
self.docker_images self.docker_images
.serialize_entry("docker_images", &mut writer)?; .serialize_entry("docker_images", &mut writer)?;
self.assets.serialize_entry("assets", &mut writer)?;
Ok(()) Ok(())
} }
pub async fn deserialize<R: AsyncRead + Unpin>(mut reader: R) -> std::io::Result<Self> { pub async fn deserialize<R: AsyncRead + Unpin>(mut reader: R) -> std::io::Result<Self> {
@@ -126,6 +129,7 @@ impl TableOfContents {
instructions: from_table(&table, "instructions")?, instructions: from_table(&table, "instructions")?,
icon: from_table(&table, "icon")?, icon: from_table(&table, "icon")?,
docker_images: from_table(&table, "docker_images")?, docker_images: from_table(&table, "docker_images")?,
assets: from_table(&table, "assets")?,
}) })
} }
} }

View File

@@ -139,11 +139,13 @@ pub struct Assets {
#[serde(default)] #[serde(default)]
pub license: Option<PathBuf>, pub license: Option<PathBuf>,
#[serde(default)] #[serde(default)]
pub instructions: Option<PathBuf>,
#[serde(default)]
pub icon: Option<PathBuf>, pub icon: Option<PathBuf>,
#[serde(default)] #[serde(default)]
pub docker_images: Option<PathBuf>, pub docker_images: Option<PathBuf>,
#[serde(default)] #[serde(default)]
pub instructions: Option<PathBuf>, pub assets: Option<PathBuf>,
} }
impl Assets { impl Assets {
pub fn license_path(&self) -> &Path { pub fn license_path(&self) -> &Path {
@@ -152,6 +154,12 @@ impl Assets {
.map(|a| a.as_path()) .map(|a| a.as_path())
.unwrap_or(Path::new("LICENSE.md")) .unwrap_or(Path::new("LICENSE.md"))
} }
pub fn instructions_path(&self) -> &Path {
self.instructions
.as_ref()
.map(|a| a.as_path())
.unwrap_or(Path::new("INSTRUCTIONS.md"))
}
pub fn icon_path(&self) -> &Path { pub fn icon_path(&self) -> &Path {
self.icon self.icon
.as_ref() .as_ref()
@@ -171,11 +179,11 @@ impl Assets {
.map(|a| a.as_path()) .map(|a| a.as_path())
.unwrap_or(Path::new("image.tar")) .unwrap_or(Path::new("image.tar"))
} }
pub fn instructions_path(&self) -> &Path { pub fn assets_path(&self) -> &Path {
self.instructions self.assets
.as_ref() .as_ref()
.map(|a| a.as_path()) .map(|a| a.as_path())
.unwrap_or(Path::new("INSTRUCTIONS.md")) .unwrap_or(Path::new("assets"))
} }
} }

View File

@@ -10,6 +10,7 @@ use crate::s9pk::builder::S9pkPacker;
use crate::s9pk::manifest::Manifest; use crate::s9pk::manifest::Manifest;
use crate::s9pk::reader::S9pkReader; use crate::s9pk::reader::S9pkReader;
use crate::util::display_none; use crate::util::display_none;
use crate::volume::Volume;
use crate::{Error, ResultExt}; use crate::{Error, ResultExt};
pub mod builder; pub mod builder;
@@ -79,6 +80,22 @@ pub fn pack(#[context] ctx: EitherContext, #[arg] path: Option<PathBuf>) -> Resu
) )
})?, })?,
) )
.assets({
let mut assets = tar::Builder::new(Vec::new()); // TODO: Ideally stream this? best not to buffer in memory
for (asset_volume, _) in manifest
.volumes
.iter()
.filter(|(_, v)| matches!(v, &&Volume::Assets {}))
{
assets.append_dir_all(
asset_volume,
path.join(manifest.assets.assets_path()).join(asset_volume),
)?;
}
std::io::Cursor::new(assets.into_inner()?)
})
.build() .build()
.pack(&ctx.developer_key()?)?; .pack(&ctx.developer_key()?)?;
outfile.sync_all()?; outfile.sync_all()?;

View File

@@ -141,4 +141,8 @@ impl<R: AsyncRead + AsyncSeek + Unpin> S9pkReader<R> {
pub async fn docker_images<'a>(&'a mut self) -> Result<ReadHandle<'a, R>, Error> { pub async fn docker_images<'a>(&'a mut self) -> Result<ReadHandle<'a, R>, Error> {
Ok(self.read_handle(self.toc.docker_images).await?) Ok(self.read_handle(self.toc.docker_images).await?)
} }
pub async fn assets<'a>(&'a mut self) -> Result<ReadHandle<'a, R>, Error> {
Ok(self.read_handle(self.toc.assets).await?)
}
} }

View File

@@ -65,7 +65,7 @@ pub async fn synchronize_all(ctx: &RpcContext) -> Result<(), Error> {
let mut fuckening = false; let mut fuckening = false;
for summary in info { for summary in info {
let id = if let Some(id) = summary.names.iter().flatten().find_map(|s| { let id = if let Some(id) = summary.names.iter().flatten().find_map(|s| {
DockerAction::uncontainer_name(s.as_str()).and_then(|id| pkg_ids.take(id)) DockerAction::uncontainer_name(s.as_str()).and_then(|(id, _)| pkg_ids.take(id))
}) { }) {
id id
} else { } else {

View File

@@ -518,6 +518,15 @@ impl std::fmt::Display for Version {
write!(f, "{}", self.string) write!(f, "{}", self.string)
} }
} }
impl std::str::FromStr for Version {
type Err = <emver::Version as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Version {
string: s.to_owned(),
version: s.parse()?,
})
}
}
impl From<emver::Version> for Version { impl From<emver::Version> for Version {
fn from(v: emver::Version) -> Self { fn from(v: emver::Version) -> Self {
Version { Version {

View File

@@ -9,6 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use crate::id::{Id, IdUnchecked}; use crate::id::{Id, IdUnchecked};
use crate::net::interface::InterfaceId; use crate::net::interface::InterfaceId;
use crate::s9pk::manifest::PackageId; use crate::s9pk::manifest::PackageId;
use crate::util::Version;
pub mod disk; pub mod disk;
@@ -65,16 +66,18 @@ where
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct CustomVolumeId<S: AsRef<str> = String>(Id<S>);
#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Volumes(IndexMap<VolumeId, Volume>); pub struct Volumes(IndexMap<VolumeId, Volume>);
impl Volumes { impl Volumes {
pub fn get_path_for(&self, pkg_id: &PackageId, volume_id: &VolumeId) -> Option<PathBuf> { pub fn get_path_for(
&self,
pkg_id: &PackageId,
version: &Version,
volume_id: &VolumeId,
) -> Option<PathBuf> {
self.0 self.0
.get(volume_id) .get(volume_id)
.map(|volume| volume.path_for(pkg_id, volume_id)) .map(|volume| volume.path_for(pkg_id, version, volume_id))
} }
pub fn to_readonly(&self) -> Self { pub fn to_readonly(&self) -> Self {
Volumes( Volumes(
@@ -121,6 +124,8 @@ pub enum Volume {
readonly: bool, readonly: bool,
}, },
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
Assets {},
#[serde(rename_all = "kebab-case")]
Pointer { Pointer {
package_id: PackageId, package_id: PackageId,
volume_id: VolumeId, volume_id: VolumeId,
@@ -134,22 +139,31 @@ pub enum Volume {
Backup { readonly: bool }, Backup { readonly: bool },
} }
impl Volume { impl Volume {
pub fn path_for(&self, pkg_id: &PackageId, volume_id: &VolumeId) -> PathBuf { pub fn path_for(&self, pkg_id: &PackageId, version: &Version, volume_id: &VolumeId) -> PathBuf {
match self { match self {
Volume::Data { .. } => Path::new(PKG_VOLUME_DIR) Volume::Data { .. } => Path::new(PKG_VOLUME_DIR)
.join(pkg_id) .join(pkg_id)
.join("volumes") .join("volumes")
.join(volume_id), .join(volume_id),
Volume::Assets {} => Path::new(PKG_VOLUME_DIR)
.join(pkg_id)
.join("assets")
.join(version.as_str())
.join(volume_id),
Volume::Pointer { Volume::Pointer {
package_id, package_id,
volume_id, volume_id,
path, path,
.. ..
} => Path::new(PKG_VOLUME_DIR) } => dbg!(Path::new(PKG_VOLUME_DIR)
.join(package_id) .join(package_id)
.join("volumes") .join("volumes")
.join(volume_id) .join(volume_id)
.join(path), .join(if path.is_absolute() {
path.strip_prefix("/").unwrap()
} else {
path.as_ref()
})),
Volume::Certificate { interface_id } => Path::new(PKG_VOLUME_DIR) Volume::Certificate { interface_id } => Path::new(PKG_VOLUME_DIR)
.join(pkg_id) .join(pkg_id)
.join("certificates") .join("certificates")
@@ -174,6 +188,7 @@ impl Volume {
pub fn readonly(&self) -> bool { pub fn readonly(&self) -> bool {
match self { match self {
Volume::Data { readonly } => *readonly, Volume::Data { readonly } => *readonly,
Volume::Assets {} => true,
Volume::Pointer { readonly, .. } => *readonly, Volume::Pointer { readonly, .. } => *readonly,
Volume::Certificate { .. } => true, Volume::Certificate { .. } => true,
Volume::Backup { readonly } => *readonly, Volume::Backup { readonly } => *readonly,

4
compat/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
**/*.rs.bk
.DS_Store
.vscode

3079
compat/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
compat/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "compat"
version = "0.1.0"
authors = ["Aiden McClelland <me@drbonez.dev>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = "2.33.3"
serde = { version="1.0.118", features=["derive", "rc"] }
serde_yaml = "0.8.17"
embassy-os = { path="../appmgr", default-features=false }

5
compat/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM alpine:latest
ADD ./target/aarch64-unknown-linux-musl/release/compat /usr/local/bin/compat
ENTRYPOINT ["compat"]

45
compat/src/main.rs Normal file
View File

@@ -0,0 +1,45 @@
use std::{fs::File, io::stdout, path::Path};
use clap::{App, Arg, SubCommand};
use embassy::config::action::{ConfigRes, SetResult};
fn main() {
let app = App::new("compat").subcommand(
SubCommand::with_name("config").subcommand(
SubCommand::with_name("get")
.arg(
Arg::with_name("mountpoint")
.help("The `mount` field from manifest.yaml")
.required(true),
)
.arg(
Arg::with_name("spec")
.help("The path to the config spec in the container")
.required(true),
),
),
);
let matches = app.get_matches();
match matches.subcommand() {
("config", Some(sub_m)) => match sub_m.subcommand() {
("get", Some(sub_m)) => {
let cfg_path =
Path::new(sub_m.value_of("mountpoint").unwrap()).join("start9/config.yaml");
let cfg = if cfg_path.exists() {
Some(serde_yaml::from_reader(File::open(cfg_path).unwrap()).unwrap())
} else {
None
};
let spec_path = Path::new(sub_m.value_of("spec").unwrap());
let spec = serde_yaml::from_reader(File::open(spec_path).unwrap()).unwrap();
serde_yaml::to_writer(stdout(), &ConfigRes { config: cfg, spec }).unwrap();
}
(subcmd, _) => {
panic!("unknown subcommand: {}", subcmd);
}
},
(subcmd, _) => {
panic!("unknown subcommand: {}", subcmd);
}
}
}