Files
doos/src/cli.cpp
T

266 lines
12 KiB
C++
Raw Normal View History

#include "cli.hpp"
#include <cctype>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <optional>
#include "container.hpp"
#include "sandbox.hpp"
#include "spec.hpp"
#include "util.hpp"
namespace doos {
namespace {
std::uint32_t parseCount(const std::string& text);
std::uint32_t parseCpus(const std::string& text);
// Leading --options, then the first positional, then everything else untouched.
struct Parsed {
Options opts;
std::optional<std::string> sha256;
bool yes = false;
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
std::vector<std::string> tail;
};
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) {
Parsed p;
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; };
std::size_t i = 0;
for (; i < rest.size(); ++i) {
const std::string& a = rest[i];
if (a == "--") { ++i; break; }
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"));
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");
}
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;
}
if (p.positional.size() < wantPositional) throw std::runtime_error("missing argument");
if (i < rest.size() && rest[i] == "--") ++i;
p.tail.assign(rest.begin() + static_cast<long>(i), rest.end());
return p;
}
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));
}
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"
" doos get_or_create [OPTIONS] NAME\n"
" doos attach [OPTIONS] NAME [-- CMD [ARGS...]]\n"
" doos delete [--yes] NAME\n"
" doos image list\n"
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
" doos image delete [--yes] NAME\n"
"\n"
"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"
"\n"
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
"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());
if (cmd == "__version" || cmd == "version") { std::puts(DOOS_VERSION); return 0; }
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);
if (cmd == "image") return cmdImage(rest);
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; }
std::printf("%-*s %-8s %-6s %-12s %-6s %s\n", 16, "NAME", "STATE", "NET", "IMAGE", "SIZE", "AGE");
for (const auto& c : all) {
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());
}
return 0;
}
int Cli::cmdGetOrCreate(std::vector<std::string> rest) {
Parsed p = parse(rest, {true, false, false}, 1);
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
Store store = Store::fromEnvironment();
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
return 0;
}
int Cli::cmdAttach(std::vector<std::string> rest) {
Parsed p = parse(rest, {true, false, false}, 1);
Store store = Store::fromEnvironment();
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
(void)created;
return Sandbox(std::move(c), std::move(p.tail)).run();
}
int Cli::cmdDelete(std::vector<std::string> rest) {
Parsed p = parse(rest, {false, true, false}, 1);
if (!p.tail.empty()) throw std::runtime_error("delete takes only a name");
Store store = Store::fromEnvironment();
std::puts(store.remove(p.positional[0], p.yes).c_str());
return 0;
}
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)) {
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE), an https URL, nor a file");
}
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());
return 0;
}
} // namespace doos