2026-10-23 20:54:55 +02:00
|
|
|
#include "cli.hpp"
|
|
|
|
|
|
2026-10-23 21:41:28 +02:00
|
|
|
#include <cctype>
|
2026-10-23 20:54:55 +02:00
|
|
|
#include <ctime>
|
2026-10-23 21:41:28 +02:00
|
|
|
#include <filesystem>
|
2026-10-23 20:54:55 +02:00
|
|
|
#include <iostream>
|
|
|
|
|
#include <optional>
|
|
|
|
|
|
|
|
|
|
#include "container.hpp"
|
|
|
|
|
#include "sandbox.hpp"
|
2026-10-23 21:41:28 +02:00
|
|
|
#include "spec.hpp"
|
|
|
|
|
#include "util.hpp"
|
2026-10-23 20:54:55 +02:00
|
|
|
|
|
|
|
|
namespace doos {
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
2026-10-23 21:59:26 +02:00
|
|
|
std::uint32_t parseCount(const std::string& text);
|
|
|
|
|
std::uint32_t parseCpus(const std::string& text);
|
|
|
|
|
|
2026-10-23 21:41:28 +02:00
|
|
|
// Leading --options, then the first positional, then everything else untouched.
|
2026-10-23 20:54:55 +02:00
|
|
|
struct Parsed {
|
2026-10-23 21:41:28 +02:00
|
|
|
Options opts;
|
|
|
|
|
std::optional<std::string> sha256;
|
2026-10-23 20:54:55 +02:00
|
|
|
bool yes = false;
|
2026-10-23 21:41:28 +02:00
|
|
|
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
|
2026-10-23 20:54:55 +02:00
|
|
|
std::vector<std::string> tail;
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-23 21:41:28 +02:00
|
|
|
struct Allowed {
|
|
|
|
|
bool container = false; // --net --image --size --ids
|
|
|
|
|
bool yes = false;
|
|
|
|
|
bool sha256 = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wantPositional) {
|
2026-10-23 20:54:55 +02:00
|
|
|
Parsed p;
|
2026-10-23 21:41:28 +02:00
|
|
|
auto value = [&](std::size_t& i, const std::string& flag) -> std::string {
|
|
|
|
|
const std::string& a = rest[i];
|
|
|
|
|
if (a.size() > flag.size() && a.compare(0, flag.size() + 1, flag + "=") == 0) return a.substr(flag.size() + 1);
|
|
|
|
|
if (i + 1 >= rest.size()) throw std::runtime_error(flag + " needs a value");
|
|
|
|
|
return rest[++i];
|
|
|
|
|
};
|
|
|
|
|
auto is = [&](const std::string& a, const std::string& flag) { return a == flag || a.rfind(flag + "=", 0) == 0; };
|
2026-10-23 20:54:55 +02:00
|
|
|
std::size_t i = 0;
|
|
|
|
|
for (; i < rest.size(); ++i) {
|
|
|
|
|
const std::string& a = rest[i];
|
|
|
|
|
if (a == "--") { ++i; break; }
|
2026-10-23 21:41:28 +02:00
|
|
|
if (allow.container && is(a, "--net")) p.opts.net = Network::parse(value(i, "--net"));
|
|
|
|
|
else if (allow.container && is(a, "--image")) p.opts.image = value(i, "--image");
|
|
|
|
|
else if (allow.container && is(a, "--size")) p.opts.size = parseSize(value(i, "--size"));
|
|
|
|
|
else if (allow.container && is(a, "--ids")) p.opts.ids = IdMap::parse(value(i, "--ids"));
|
2026-10-23 21:59:26 +02:00
|
|
|
else if (allow.container && is(a, "--memory")) p.opts.memory = parseSize(value(i, "--memory"));
|
|
|
|
|
else if (allow.container && is(a, "--pids")) p.opts.pids = parseCount(value(i, "--pids"));
|
|
|
|
|
else if (allow.container && is(a, "--cpus")) p.opts.milliCpus = parseCpus(value(i, "--cpus"));
|
|
|
|
|
else if (allow.container && is(a, "--hardening")) {
|
|
|
|
|
std::string v = value(i, "--hardening");
|
|
|
|
|
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
|
|
|
|
p.opts.relaxed = (v == "relaxed");
|
|
|
|
|
}
|
2026-10-23 21:41:28 +02:00
|
|
|
else if (allow.yes && a == "--yes") p.yes = true;
|
|
|
|
|
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
|
|
|
|
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
|
|
|
|
else if (p.positional.size() < wantPositional) p.positional.push_back(a);
|
|
|
|
|
else break;
|
2026-10-23 20:54:55 +02:00
|
|
|
}
|
2026-10-23 21:41:28 +02:00
|
|
|
if (p.positional.size() < wantPositional) throw std::runtime_error("missing argument");
|
2026-10-23 20:54:55 +02:00
|
|
|
if (i < rest.size() && rest[i] == "--") ++i;
|
|
|
|
|
p.tail.assign(rest.begin() + static_cast<long>(i), rest.end());
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 21:59:26 +02:00
|
|
|
std::uint32_t parseCount(const std::string& text) {
|
|
|
|
|
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
|
|
|
|
throw std::runtime_error("invalid number '" + text + "'");
|
|
|
|
|
}
|
|
|
|
|
return static_cast<std::uint32_t>(std::stoul(text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "2", "0.5", "1.25" cpus -> thousandths; "0" means all cores.
|
|
|
|
|
std::uint32_t parseCpus(const std::string& text) {
|
|
|
|
|
std::size_t dot = text.find('.');
|
|
|
|
|
std::string whole = text.substr(0, dot), frac = dot == std::string::npos ? "" : text.substr(dot + 1);
|
|
|
|
|
if (whole.empty() || whole.size() > 5 || frac.size() > 3 ||
|
|
|
|
|
whole.find_first_not_of("0123456789") != std::string::npos ||
|
|
|
|
|
frac.find_first_not_of("0123456789") != std::string::npos) {
|
|
|
|
|
throw std::runtime_error("invalid cpus '" + text + "' (examples: 2, 0.5, 1.25, 0 for all cores)");
|
|
|
|
|
}
|
|
|
|
|
while (frac.size() < 3) frac += '0';
|
|
|
|
|
return static_cast<std::uint32_t>(std::stoul(whole)) * 1000 + static_cast<std::uint32_t>(std::stoul(frac));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 20:54:55 +02:00
|
|
|
std::string age(long created) {
|
|
|
|
|
long d = std::time(nullptr) - created;
|
|
|
|
|
if (d < 120) return std::to_string(d) + "s";
|
|
|
|
|
if (d < 7200) return std::to_string(d / 60) + "m";
|
|
|
|
|
if (d < 172800) return std::to_string(d / 3600) + "h";
|
|
|
|
|
return std::to_string(d / 86400) + "d";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
void Cli::usage(std::FILE* out) {
|
|
|
|
|
std::fputs(
|
|
|
|
|
"doos " DOOS_VERSION " - user-level containers (overlayfs + namespaces)\n"
|
|
|
|
|
"\n"
|
|
|
|
|
"usage:\n"
|
|
|
|
|
" doos list\n"
|
2026-10-23 21:41:28 +02:00
|
|
|
" doos get_or_create [OPTIONS] NAME\n"
|
|
|
|
|
" doos attach [OPTIONS] NAME [-- CMD [ARGS...]]\n"
|
2026-10-23 20:54:55 +02:00
|
|
|
" doos delete [--yes] NAME\n"
|
2026-10-23 21:41:28 +02:00
|
|
|
" doos image list\n"
|
|
|
|
|
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
|
|
|
|
" doos image delete [--yes] NAME\n"
|
2026-10-23 22:08:37 +02:00
|
|
|
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
|
2026-10-23 20:54:55 +02:00
|
|
|
"\n"
|
2026-10-23 21:59:26 +02:00
|
|
|
"options:\n"
|
|
|
|
|
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n"
|
|
|
|
|
" --net slirp|pasta|host|none network, default slirp (pasta: host-like, private sockets) [fixed]\n"
|
|
|
|
|
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
|
|
|
|
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
|
|
|
|
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
|
|
|
|
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
|
|
|
|
" --pids N process cap, 16..4194304, default 4096\n"
|
|
|
|
|
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
|
|
|
|
|
" size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
|
2026-10-23 21:41:28 +02:00
|
|
|
"\n"
|
2026-10-23 21:59:26 +02:00
|
|
|
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
|
2026-10-23 20:54:55 +02:00
|
|
|
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
|
|
|
|
|
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
|
|
|
|
out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::run() {
|
|
|
|
|
if (args_.empty() || args_[0] == "-h" || args_[0] == "--help" || args_[0] == "help") {
|
|
|
|
|
usage(args_.empty() ? stderr : stdout);
|
|
|
|
|
return args_.empty() ? 2 : 0;
|
|
|
|
|
}
|
|
|
|
|
std::string cmd = args_[0];
|
|
|
|
|
for (auto& ch : cmd) if (ch == '-') ch = '_';
|
|
|
|
|
std::vector<std::string> rest(args_.begin() + 1, args_.end());
|
2026-10-23 21:41:28 +02:00
|
|
|
if (cmd == "__version" || cmd == "version") { std::puts(DOOS_VERSION); return 0; }
|
2026-10-23 20:54:55 +02:00
|
|
|
if (cmd == "list") return cmdList();
|
|
|
|
|
if (cmd == "get_or_create") return cmdGetOrCreate(rest);
|
|
|
|
|
if (cmd == "attach") return cmdAttach(rest);
|
|
|
|
|
if (cmd == "delete") return cmdDelete(rest);
|
2026-10-23 21:41:28 +02:00
|
|
|
if (cmd == "image") return cmdImage(rest);
|
2026-10-23 22:08:37 +02:00
|
|
|
if (cmd == "apparmor") return cmdApparmor();
|
2026-10-23 20:54:55 +02:00
|
|
|
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
|
|
|
|
|
usage(stderr);
|
|
|
|
|
return 2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdList() {
|
|
|
|
|
Store store = Store::fromEnvironment();
|
|
|
|
|
auto all = store.list();
|
|
|
|
|
if (all.empty()) { std::puts("no containers"); return 0; }
|
2026-10-23 21:41:28 +02:00
|
|
|
std::printf("%-*s %-8s %-6s %-12s %-6s %s\n", 16, "NAME", "STATE", "NET", "IMAGE", "SIZE", "AGE");
|
2026-10-23 20:54:55 +02:00
|
|
|
for (const auto& c : all) {
|
2026-10-23 21:41:28 +02:00
|
|
|
std::printf("%-*s %-8s %-6s %-12s %-6s %s\n", 16, c.name().c_str(), c.runningPid() ? "running" : "stopped",
|
|
|
|
|
Network::name(c.net()).c_str(), c.imageMode() ? c.image().c_str() : "host",
|
|
|
|
|
formatSize(c.sizeCap()).c_str(), age(c.created()).c_str());
|
2026-10-23 20:54:55 +02:00
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdGetOrCreate(std::vector<std::string> rest) {
|
2026-10-23 21:41:28 +02:00
|
|
|
Parsed p = parse(rest, {true, false, false}, 1);
|
2026-10-23 20:54:55 +02:00
|
|
|
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
|
|
|
|
|
Store store = Store::fromEnvironment();
|
2026-10-23 21:41:28 +02:00
|
|
|
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
2026-10-23 20:54:55 +02:00
|
|
|
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdAttach(std::vector<std::string> rest) {
|
2026-10-23 21:41:28 +02:00
|
|
|
Parsed p = parse(rest, {true, false, false}, 1);
|
2026-10-23 20:54:55 +02:00
|
|
|
Store store = Store::fromEnvironment();
|
2026-10-23 21:41:28 +02:00
|
|
|
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
2026-10-23 20:54:55 +02:00
|
|
|
(void)created;
|
|
|
|
|
return Sandbox(std::move(c), std::move(p.tail)).run();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdDelete(std::vector<std::string> rest) {
|
2026-10-23 21:41:28 +02:00
|
|
|
Parsed p = parse(rest, {false, true, false}, 1);
|
2026-10-23 20:54:55 +02:00
|
|
|
if (!p.tail.empty()) throw std::runtime_error("delete takes only a name");
|
|
|
|
|
Store store = Store::fromEnvironment();
|
2026-10-23 21:41:28 +02:00
|
|
|
std::puts(store.remove(p.positional[0], p.yes).c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 22:08:37 +02:00
|
|
|
int Cli::cmdApparmor() {
|
|
|
|
|
std::fputs(Sandbox::apparmorProfile().c_str(), stdout);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 21:41:28 +02:00
|
|
|
int Cli::cmdImage(std::vector<std::string> rest) {
|
|
|
|
|
if (rest.empty()) throw std::runtime_error("image needs a subcommand: list, add, delete");
|
|
|
|
|
std::string sub = rest[0];
|
|
|
|
|
std::vector<std::string> tail(rest.begin() + 1, rest.end());
|
|
|
|
|
if (sub == "list") return cmdImageList();
|
|
|
|
|
if (sub == "add") return cmdImageAdd(tail);
|
|
|
|
|
if (sub == "delete") return cmdImageDelete(tail);
|
|
|
|
|
throw std::runtime_error("unknown image subcommand '" + sub + "' (list, add, delete)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdImageList() {
|
|
|
|
|
Store store = Store::fromEnvironment();
|
|
|
|
|
auto installed = store.images().list();
|
|
|
|
|
std::printf("%-*s %-6s %-7s %s\n", 16, "IMAGE", "SIZE", "IDS", "STATE");
|
|
|
|
|
for (const auto& i : installed) {
|
|
|
|
|
std::printf("%-*s %-6s %-7s %s\n", 16, i.name.c_str(), formatSize(i.bytes).c_str(), IdMap::name(i.ids).c_str(),
|
|
|
|
|
"installed");
|
|
|
|
|
}
|
|
|
|
|
for (const auto& n : Catalog::names()) {
|
|
|
|
|
bool have = false;
|
|
|
|
|
for (const auto& i : installed) have = have || i.name == n;
|
|
|
|
|
if (!have) std::printf("%-*s %-6s %-7s %s\n", 16, n.c_str(), "-", "-", "available");
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdImageAdd(std::vector<std::string> rest) {
|
|
|
|
|
Parsed p = parse(rest, {false, false, true}, 1);
|
|
|
|
|
if (p.tail.size() > 1) throw std::runtime_error("image add takes SOURCE and an optional NAME");
|
|
|
|
|
const std::string& source = p.positional[0];
|
|
|
|
|
ImageSource src;
|
|
|
|
|
bool isUrl = source.rfind("https://", 0) == 0;
|
|
|
|
|
bool isFile = !isUrl && source.find('/') != std::string::npos;
|
|
|
|
|
if (source.rfind("http://", 0) == 0) throw std::runtime_error("plain http is refused; use https");
|
|
|
|
|
if (!isUrl && !isFile && Catalog::knows(ImageStore::normalize(source))) {
|
|
|
|
|
src = Catalog::resolve(ImageStore::normalize(source));
|
|
|
|
|
if (p.sha256) src.sha256 = *p.sha256;
|
|
|
|
|
} else if (isUrl) {
|
|
|
|
|
if (!p.sha256) throw std::runtime_error("a URL image needs --sha256 HEX (unverifiable input is rejected)");
|
|
|
|
|
src.location = source;
|
|
|
|
|
src.sha256 = *p.sha256;
|
|
|
|
|
} else {
|
|
|
|
|
if (!std::filesystem::is_regular_file(source)) {
|
2026-10-23 21:59:26 +02:00
|
|
|
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE), an https URL, nor a file");
|
2026-10-23 21:41:28 +02:00
|
|
|
}
|
|
|
|
|
src.location = std::filesystem::absolute(source).string();
|
|
|
|
|
src.isFile = true;
|
|
|
|
|
if (p.sha256) src.sha256 = *p.sha256;
|
|
|
|
|
}
|
|
|
|
|
if (!p.tail.empty()) {
|
|
|
|
|
src.name = ImageStore::normalize(p.tail[0]);
|
|
|
|
|
} else if (src.name.empty()) {
|
|
|
|
|
std::string base = std::filesystem::path(src.location).filename().string();
|
|
|
|
|
for (const char* ext : {".tar.gz", ".tar.xz", ".tar.zst", ".tgz", ".tar"}) {
|
|
|
|
|
std::string e = ext;
|
|
|
|
|
if (base.size() > e.size() && base.compare(base.size() - e.size(), e.size(), e) == 0) base.erase(base.size() - e.size());
|
|
|
|
|
}
|
|
|
|
|
for (auto& ch : base) ch = std::isalnum(static_cast<unsigned char>(ch)) ? static_cast<char>(std::tolower(ch)) : '-';
|
|
|
|
|
src.name = base.substr(0, spec::kMaxNameLen);
|
|
|
|
|
}
|
|
|
|
|
Store store = Store::fromEnvironment();
|
|
|
|
|
ImageInfo info = store.images().add(src, IdMap::best());
|
|
|
|
|
std::printf("added image %s (%s, ids %s)\n", info.name.c_str(), formatSize(info.bytes).c_str(), IdMap::name(info.ids).c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int Cli::cmdImageDelete(std::vector<std::string> rest) {
|
|
|
|
|
Parsed p = parse(rest, {false, true, false}, 1);
|
|
|
|
|
if (!p.tail.empty()) throw std::runtime_error("image delete takes only a name");
|
|
|
|
|
Store store = Store::fromEnvironment();
|
|
|
|
|
std::string name = ImageStore::normalize(p.positional[0]);
|
|
|
|
|
std::puts(store.images().remove(name, p.yes, store.containersUsing(name)).c_str());
|
2026-10-23 20:54:55 +02:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace doos
|