Commit 5e1f2c64 authored by jan.koester's avatar jan.koester
Browse files

test

parent 707c9e0b
Loading
Loading
Loading
Loading
+3 −0
Changes for CMakeLists.txt: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -106,6 +106,9 @@ webctl_module(filesystems modules/filesystems/filesystems.cpp modules/common/blo
target_link_libraries(webctl_mod_filesystems PRIVATE sysbus::sysbus)
# SNMP notifications: settings and status of snmpdd over sysbus; needs
# snmpdd/api.h (snmpdd-dev)
# containers: a client of containerd (containerd/api.h, containerd-dev)
webctl_module(containers modules/containers/containers.cpp)
target_link_libraries(webctl_mod_containers PRIVATE sysbus::sysbus)
webctl_module(snmp modules/snmp/snmp.cpp)
target_link_libraries(webctl_mod_snmp PRIVATE sysbus::sysbus)
# time: NTP client/server of ntpplus over sysbus; needs ntpplus/api.h
+322 −0
Changes for modules/containers/containers.cpp: 322 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*******************************************************************************
Copyright (c) 2026, Jan Koester jan.koester@gmx.net
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************/

/* webctl module "containers": containers from Docker/OCI images, done by
 * containerd over its sysbus API (include/containerd/api.h).
 *
 *   list                  -> {containers, images, jobs, bridges} or
 *                            {unavailable} when containerd isn't there
 *       containers: [{name, image, state, pid, started, exitCode, restarts,
 *           error?, network, address?, autostart, restart, memory, tasks}]
 *       images: [{image, id, digest, size, layers, created, pulled,
 *           architecture, entrypoint?, cmd?, workdir?, user?, env[], ports[],
 *           volumes[], usedBy[]}]
 *       jobs: [{id, title, state, progress, seconds, finished, error?}]
 *       bridges: the host's bridges, for network "bridge"
 *   get {name}            -> {container: settings and state}
 *   create {container}    a container: name, image, entrypoint[], args[],
 *   update {name, container}   env[], user, workdir, hostname, boot,
 *                         autostart, restart, network, bridge, address,
 *                         gateway, dns[], binds[], tmpfs[], readOnly,
 *                         memoryMax, tasksMax, capabilities[],
 *                         dropCapabilities[] (update: name renames)
 *   delete {name}         (its files go in the background)
 *   start / stop / restart {name}      kill {name, signal}
 *   log {name, lines}     -> {lines[]}
 *   pull {image}          in the background -> {background, job}
 *   removeImage {image}
 */

#include <cstdlib>
#include <cstring>
#include <set>
#include <string>
#include <vector>

#include <dirent.h>
#include <sys/stat.h>

#include <containerd/api.h>
#include <sysbus/client.h>
#include <webctl/module.h>

using webctl::error;
using webctl::jstr;
using webctl::tr;

namespace {

using Fields = std::vector<std::pair<std::string, std::string>>;

const std::set<std::string> Flags = {"boot", "autostart", "read_only"};
const std::set<std::string> Numbers = {"pid", "started", "exit_code", "restarts", "memory", "tasks", "size", "layers",
                                       "pulled", "memory_max", "tasks_max", "seconds", "finished"};
// repeated keys -> JSON list names
const std::vector<std::pair<std::string, std::string>> Lists = {
    {"entrypoint", "entrypoint"}, {"arg", "args"}, {"env", "env"}, {"dns", "dns"}, {"bind", "binds"},
    {"tmpfs", "tmpfs"}, {"capability", "capabilities"}, {"drop_capability", "dropCapabilities"},
    {"port", "ports"}, {"volume", "volumes"}, {"used_by", "usedBy"}};

std::string camel(const std::string &k) {
    std::string out;
    for (size_t i = 0; i < k.size(); ++i) {
        if (k[i] == '_' && i + 1 < k.size()) out += (char)toupper((unsigned char)k[++i]);
        else out += k[i];
    }
    return out;
}

const std::string *listName(const std::string &k) {
    for (auto &l : Lists)
        if (l.first == k) return &l.second;
    return nullptr;
}

json_object *list(json_object *o, const std::string &key) {
    json_object *a = nullptr;
    if (!json_object_object_get_ex(o, key.c_str(), &a) || !a) {
        a = json_object_new_array();
        json_object_object_add(o, key.c_str(), a);
    }
    return a;
}

void addField(json_object *o, const std::string &k, const std::string &v, bool imageRecord) {
    // in an image record, entrypoint and cmd are one line each
    if (!imageRecord)
        if (const std::string *l = listName(k)) {
            json_object_array_add(list(o, *l), jstr(v));
            return;
        }
    if (imageRecord && (k == "env" || k == "port" || k == "volume" || k == "used_by")) {
        json_object_array_add(list(o, *listName(k)), jstr(v));
        return;
    }
    std::string name = camel(k);
    if (Flags.count(k) || k == "autostart") json_object_object_add(o, name.c_str(), json_object_new_boolean(v == "yes"));
    else if (Numbers.count(k)) json_object_object_add(o, name.c_str(), json_object_new_int64(std::strtoll(v.c_str(), nullptr, 10)));
    else json_object_object_add(o, name.c_str(), jstr(v));
}

// a containerd call; on failure the error object to return
json_object *callContainerd(const char *method, const Fields &args, sysbus::Message &reply) {
    sysbus::Client bus;
    if (!bus.connect())
        return error(tr("sysbus isn't reachable: %1", "sysbus ist nicht erreichbar: %1", {bus.lastError()}), 503);
    Fields all = args;
    all.emplace_back("lang", webctl::language());
    reply = bus.callService(containerd::Service, method, all, 60000);
    if (reply.type != sysbus::MessageType::Error) return nullptr;
    const std::string &name = reply.member;
    if (name == sysbus::error::ServiceUnknown)
        return error(tr("containerd isn't running (Services: containerd)", "containerd läuft nicht (Dienste: containerd)"), 503);
    int status = name == sysbus::error::InvalidArgs ? 400
               : name == sysbus::error::AccessDenied ? 403
               : name == containerd::error::NotFound ? 404
               : name == containerd::error::Busy ? 409
               : name == containerd::error::Unavailable ? 503 : 500;
    return error(reply.get("message", name), status);
}

// records of a list reply, each starting with key, into a JSON list
json_object *records(const sysbus::Message &r, const std::string &key, const char *nameKey, bool imageRecord = false) {
    json_object *out = json_object_new_array();
    json_object *cur = nullptr;
    for (auto &f : r.fields) {
        if (f.first == key) {
            cur = json_object_new_object();
            if (key == "job") json_object_object_add(cur, nameKey, json_object_new_int64(std::strtoll(f.second.c_str(), nullptr, 10)));
            else json_object_object_add(cur, nameKey, jstr(f.second));
            if (imageRecord)
                for (const char *l : {"env", "ports", "volumes", "usedBy"}) list(cur, l);
            json_object_array_add(out, cur);
        } else if (cur) {
            addField(cur, f.first, f.second, imageRecord);
        }
    }
    return out;
}

// a reply that went to the background: {ok, background, job}
json_object *changed(const sysbus::Message &r) {
    json_object *o = webctl::ok();
    if (r.get("background") == "yes") {
        json_object *jobs = records(r, "job", "id");
        json_object_object_add(o, "background", json_object_new_boolean(1));
        if (json_object_array_length(jobs)) json_object_object_add(o, "job", json_object_get(json_object_array_get_idx(jobs, 0)));
        json_object_put(jobs);
        return o;
    }
    for (auto &f : r.fields)
        if (f.first == "state" || f.first == "container" || f.first == "image") json_object_object_add(o, f.first.c_str(), jstr(f.second));
    return o;
}

json_object *simple(const char *method, const Fields &args) {
    sysbus::Message r;
    if (json_object *e = callContainerd(method, args, r)) return e;
    return changed(r);
}

std::vector<std::string> bridges() {
    std::vector<std::string> out;
    if (DIR *d = opendir("/sys/class/net")) {
        while (struct dirent *e = readdir(d)) {
            struct stat st;
            if (e->d_name[0] != '.' && stat(("/sys/class/net/" + std::string(e->d_name) + "/bridge").c_str(), &st) == 0)
                out.push_back(e->d_name);
        }
        closedir(d);
    }
    return out;
}

json_object *listAll() {
    json_object *out = json_object_new_object();
    sysbus::Message r;
    if (json_object *e = callContainerd(containerd::method::ListContainers, {}, r)) {
        json_object_object_add(out, "unavailable", json_object_get(json_object_object_get(e, "error")));
        json_object_put(e);
        return out;
    }
    json_object_object_add(out, "containers", records(r, "container", "name"));
    if (json_object *e = callContainerd(containerd::method::ListImages, {}, r)) return json_object_put(out), e;
    json_object_object_add(out, "images", records(r, "image", "image", true));
    if (json_object *e = callContainerd(containerd::method::ListJobs, {}, r)) return json_object_put(out), e;
    json_object_object_add(out, "jobs", records(r, "job", "id"));
    json_object *b = json_object_new_array();
    for (auto &n : bridges()) json_object_array_add(b, jstr(n));
    json_object_object_add(out, "bridges", b);
    return out;
}

const char *yesNo(bool b) { return b ? "yes" : "no"; }

// the UI's container as containerd's fields; "" or why not
std::string settingsFields(json_object *c, Fields &f, bool create) {
    if (!c || !json_object_is_type(c, json_type_object)) return tr("no container given", "kein Container angegeben");
    auto text = [&](const char *key, const char *field) {
        if (webctl::has(c, key)) f.emplace_back(field, webctl::str(c, key));
    };
    auto flag = [&](const char *key, const char *field) {
        if (webctl::has(c, key)) f.emplace_back(field, yesNo(webctl::boolean(c, key)));
    };
    auto number = [&](const char *key, const char *field) {
        if (webctl::has(c, key)) f.emplace_back(field, std::to_string(webctl::integer(c, key, 0)));
    };
    if (create) text("name", "name");
    text("image", "image");
    text("user", "user");
    text("workdir", "workdir");
    text("hostname", "hostname");
    flag("boot", "boot");
    flag("autostart", "autostart");
    text("restart", "restart");
    text("network", "network");
    text("bridge", "bridge");
    text("address", "address");
    text("gateway", "gateway");
    flag("readOnly", "read_only");
    number("memoryMax", "memory_max");
    number("tasksMax", "tasks_max");
    for (auto &l : Lists) {
        json_object *a = nullptr;
        if (!json_object_object_get_ex(c, l.second.c_str(), &a) || !a) continue;
        if (!json_object_is_type(a, json_type_array)) return tr("%1 must be a list", "%1 muss eine Liste sein", {l.second});
        // an empty list: the key once, empty (clears it)
        if (!json_object_array_length(a)) f.emplace_back(l.first, "");
        for (size_t i = 0; i < json_object_array_length(a); ++i)
            if (const char *s = json_object_get_string(json_object_array_get_idx(a, i)))
                if (*s) f.emplace_back(l.first, s);
    }
    return "";
}

json_object *get(const std::string &name) {
    sysbus::Message r;
    if (json_object *e = callContainerd(containerd::method::GetContainer, {{"name", name}}, r)) return e;
    json_object *c = json_object_new_object();
    for (const auto &l : Lists)
        if (l.first != "port" && l.first != "volume" && l.first != "used_by") list(c, l.second);
    for (auto &f : r.fields) {
        if (f.first == "container") json_object_object_add(c, "name", jstr(f.second));
        else addField(c, f.first, f.second, false);
    }
    json_object *out = json_object_new_object();
    json_object_object_add(out, "container", c);
    return out;
}

json_object *log(const std::string &name, long lines) {
    sysbus::Message r;
    if (json_object *e = callContainerd(containerd::method::ContainerLog,
                                        {{"name", name}, {"lines", std::to_string(lines > 0 ? lines : 200)}}, r))
        return e;
    json_object *a = json_object_new_array();
    for (auto &f : r.fields)
        if (f.first == "line") json_object_array_add(a, jstr(f.second));
    json_object *out = json_object_new_object();
    json_object_object_add(out, "lines", a);
    return out;
}

json_object *call(const webctl_request *req) {
    webctl::LanguageScope lang(req->lang);
    std::string action = req->action;
    json_object *a = req->args;
    namespace m = containerd::method;
    std::string name = webctl::str(a, "name");

    if (action == "list") return listAll();
    if (action == "get") return get(name);
    if (action == "log") return log(name, webctl::integer(a, "lines", 200));
    if (action == "create" || action == "update") {
        json_object *c = nullptr;
        json_object_object_get_ex(a, "container", &c);
        Fields f;
        std::string why = settingsFields(c, f, action == "create");
        if (!why.empty()) return error(why);
        if (action == "update") {
            f.insert(f.begin(), {"name", name});
            std::string newName = webctl::str(c, "name", name);
            if (newName != name) f.emplace_back("new_name", newName);
        }
        return simple(action == "create" ? m::CreateContainer : m::EditContainer, f);
    }
    if (action == "delete") return simple(m::DeleteContainer, {{"name", name}});
    if (action == "start") return simple(m::StartContainer, {{"name", name}});
    if (action == "stop") return simple(m::StopContainer, {{"name", name}});
    if (action == "restart") return simple(m::RestartContainer, {{"name", name}});
    if (action == "kill") return simple(m::KillContainer, {{"name", name}, {"signal", webctl::str(a, "signal", "KILL")}});
    if (action == "pull") return simple(m::PullImage, {{"image", webctl::str(a, "image")}});
    if (action == "removeImage") return simple(m::RemoveImage, {{"image", webctl::str(a, "image")}});
    return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404);
}

} // namespace

WEBCTL_MODULE({WEBCTL_MODULE_ABI, "containers", "Containers", 32, nullptr, nullptr, call});
+3 −0
Changes for www/app.css: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -207,3 +207,6 @@ ul.loss { margin: -.4em 0 .9em; padding-left: 1.3em; font-size: .92em; }
.job-list li { margin: .2em 0; }
pre.log { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .85em; white-space: pre-wrap; max-height: 24em; overflow: auto;
          background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: .6em .8em; }
/* folded parts of a dialog (containers: advanced settings) */
dialog details { margin: .4em 0 .8em; }
dialog details > summary { cursor: pointer; color: var(--muted); margin-bottom: .5em; }
+356 −0

File added.

Preview size limit exceeded, changes collapsed.