secubox-deb/docs/superpowers/plans/2026-05-18-dropletctl.md
CyberMind 26b747dec4
feat(droplet): static-publisher CLI dropletctl — port from OpenWrt (closes #196) (#199)
* docs(spec): dropletctl design — port from OpenWrt, static-only v1.1.0 (ref #196)

Brainstormed scope:
- in:  publish/remove/rename/list, HTML+ZIP+tarball, idempotent overwrite,
       full delegation to metablogizerctl site publish, bats unit tests
       with PATH-shimmed stubs, debian rules + changelog + Depends bump
- out: streamlit, hexo, gitea git-versioning, atomic rollback (v1.2.0+)

Captures the 5-section design just walked through:
  1. Architecture (thin orchestrator → metablogizerctl)
  2. Components & interfaces (file layout, CLI surface, output contract)
  3. Data flow + error handling (10-step happy path, 6 exit codes)
  4. Testing (10 bats cases, fixtures, PATH-shimmed delegate stubs)
  5. Packaging + deploy (debian/rules + control + changelog)

Per project convention saved under docs/superpowers/specs/. Awaiting user
review before invoking superpowers:writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plan): dropletctl implementation plan — 11 tasks, TDD, 13 bats cases (ref #196)

Bite-sized TDD tasks per superpowers:writing-plans convention:

  Task 1  scaffold bats + fixtures + delegate stubs + CLI skeleton
  Task 2  publish arg validation (3 error cases)
  Task 3  name sanitization + single HTML happy path + metablogizerctl delegate
  Task 4  ZIP extraction with single-nested-dir unwrap
  Task 5  tarball extraction + plain-directory input
  Task 6  pin idempotent re-publish behavior
  Task 7  remove subcommand + TOML section delete
  Task 8  rename subcommand
  Task 9  list subcommand
  Task 10 debian packaging (rules + control + changelog 1.0.1 -> 1.1.0)
  Task 11 lint + final 13/13 bats sweep

Spec coverage: every spec requirement maps to a task. Spec deviation noted:
spec said Depends >=2.0.0 but live metablogizer is 1.1.1; plan uses >=1.1
so apt doesn't refuse. Hardware deploy on gk2 explicitly deferred to a
post-merge manual step (script included).

Self-reviewed: zero placeholders, no 'similar to Task N' weasel words,
function names consistent (sanitize_name / stage_content /
unwrap_single_nested / upsert_toml_site / remove_toml_site /
default_domain / cmd_publish/remove/rename/list). Caught + fixed a
spec coverage gap (plain-directory input was omitted) in Task 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(droplet): scaffold dropletctl CLI + bats harness (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): publish arg validation (3 error cases) (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): name sanitization + HTML staging + metablogizerctl delegation (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): ZIP extraction with single-nested-dir unwrap (ref #196)

* feat(droplet): tarball + plain-directory input (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(droplet): pin idempotent re-publish behavior (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): remove subcommand + TOML section delete (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): rename subcommand (ref #196)

* fix(droplet): rename — collision guard + same-name guard + delegate-asymmetry comments (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(droplet): list subcommand (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* build(droplet): bump to 1.1.0 + add rsync/unzip/python3/metablogizer runtime deps (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(droplet): rename stores bare base domain + reject invalid domain in publish (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(droplet): strengthen rename-domain test to exercise legacy-FQDN path (ref #196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:20:02 +02:00

38 KiB
Raw Blame History

dropletctl CLI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build /usr/sbin/dropletctl in the secubox-droplet Debian package — port the OpenWrt CLI to Debian conventions so the existing FastAPI consumer can finally publish, remove, rename, and list droplet sites.

Architecture: A ~180-line bash orchestrator at packages/secubox-droplet/sbin/dropletctl. It validates input, sanitizes names, stages content (HTML / .zip / .tar.gz / .tgz), writes a [sites.<name>] block to /etc/secubox/droplet.toml, then delegates all nginx + HAProxy + mitmproxy work to metablogizerctl site publish <name>. Tests are bats unit cases with PATH-shimmed delegate stubs in tests/fixtures/bin/. Packaging updates: install line in debian/rules, Depends: bump in debian/control, changelog bump 1.0.1 → 1.1.0.

Tech Stack: bash 5 (strict mode), bats-core for tests, python3 stdlib (tomllib read + manual TOML emission for write), rsync, unzip, tar, logger, GNU find. Reference: OpenWrt source /home/reepost/CyberMindStudio/secubox-openwrt/package/secubox/secubox-app-droplet/files/usr/sbin/dropletctl. Spec: docs/superpowers/specs/2026-05-18-dropletctl-design.md (commit 5e6e1d83).


File structure

packages/secubox-droplet/
├── sbin/
│   └── dropletctl                            ← NEW: ~180-line bash CLI
├── tests/
│   ├── test_dropletctl.bats                  ← NEW: 10 bats cases
│   ├── helpers.bash                          ← NEW: setup_suite, stub bin dir, fixture gen
│   └── fixtures/
│       └── bin/
│           ├── metablogizerctl               ← NEW: stub (logs args, exit 0)
│           └── logger                        ← NEW: stub (no-op, exit 0)
└── debian/
    ├── rules                                 ← MODIFY: + sbin/ install block
    ├── control                               ← MODIFY: + secubox-metablogizer dep
    └── changelog                             ← MODIFY: bump 1.0.1 → 1.1.0

Spec deviation noted: the spec said Depends: secubox-metablogizer (>= 2.0.0) but live metablogizer is at 1.1.1-1~bookworm1. This plan uses >= 1.1 so apt doesn't refuse to install.


Task 1: Bats infrastructure + fixtures + delegate stubs

Files:

  • Create: packages/secubox-droplet/tests/helpers.bash

  • Create: packages/secubox-droplet/tests/fixtures/bin/metablogizerctl

  • Create: packages/secubox-droplet/tests/fixtures/bin/logger

  • Create: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Create the metablogizerctl stub

mkdir -p packages/secubox-droplet/tests/fixtures/bin
cat > packages/secubox-droplet/tests/fixtures/bin/metablogizerctl <<'EOF'
#!/usr/bin/env bash
# Stub for bats tests — logs args to $STUB_LOG, exit 0 unless STUB_METABLOGIZERCTL_FAIL=1.
printf 'metablogizerctl %s\n' "$*" >> "${STUB_LOG:-/dev/null}"
if [[ "${STUB_METABLOGIZERCTL_FAIL:-0}" = "1" ]]; then
    echo "stub: forced failure" >&2
    exit 1
fi
exit 0
EOF
chmod +x packages/secubox-droplet/tests/fixtures/bin/metablogizerctl
  • Step 2: Create the logger stub
cat > packages/secubox-droplet/tests/fixtures/bin/logger <<'EOF'
#!/usr/bin/env bash
# Stub for bats tests — logs args, exit 0.
printf 'logger %s\n' "$*" >> "${STUB_LOG:-/dev/null}"
exit 0
EOF
chmod +x packages/secubox-droplet/tests/fixtures/bin/logger
  • Step 3: Create the helpers.bash test fixture-bootstrap
cat > packages/secubox-droplet/tests/helpers.bash <<'EOF'
#!/usr/bin/env bash
# Shared bats helpers for dropletctl tests.

# REPO_ROOT = top of the git checkout. PACKAGE_ROOT = packages/secubox-droplet/.
export REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)"
export PACKAGE_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
export DROPLETCTL="$PACKAGE_ROOT/sbin/dropletctl"

setup() {
    # Per-test temp dir; gone after teardown.
    TMP="$(mktemp -d "${BATS_TMPDIR}/droplet.XXXXXX")"
    export TMP
    export STUB_LOG="$TMP/stub.log"
    export SITES_DIR="$TMP/srv/metablogizer/sites"
    export TOML_PATH="$TMP/etc/secubox/droplet.toml"
    mkdir -p "$SITES_DIR" "$(dirname "$TOML_PATH")"
    : > "$STUB_LOG"
    # Empty TOML scaffold — dropletctl creates [sites.<name>] sections in it.
    printf '[droplet]\ndefault_domain = "gk2.secubox.in"\n' > "$TOML_PATH"
    # PATH-shim the delegate stubs.
    export PATH="$PACKAGE_ROOT/tests/fixtures/bin:$PATH"
}

teardown() {
    rm -rf "$TMP"
}

# Generate a 1-file HTML fixture at $1.
make_html() {
    local out="$1"
    printf '<!doctype html><html><body><h1>fixture</h1></body></html>\n' > "$out"
}

# Generate a zip fixture with one nested top-level dir.
make_zip_nested() {
    local out="$1" workdir
    workdir="$(mktemp -d "$TMP/mkzip.XXXXXX")"
    mkdir "$workdir/site"
    make_html "$workdir/site/index.html"
    printf 'body { color: red; }\n' > "$workdir/site/style.css"
    ( cd "$workdir" && zip -qr "$out" site )
    rm -rf "$workdir"
}

# Generate a tarball fixture with one nested top-level dir.
make_tarball_nested() {
    local out="$1" workdir
    workdir="$(mktemp -d "$TMP/mktar.XXXXXX")"
    mkdir "$workdir/site"
    make_html "$workdir/site/index.html"
    printf 'body { color: red; }\n' > "$workdir/site/style.css"
    ( cd "$workdir" && tar -czf "$out" site )
    rm -rf "$workdir"
}
EOF
  • Step 4: Create the bats file with a single smoke test
cat > packages/secubox-droplet/tests/test_dropletctl.bats <<'EOF'
#!/usr/bin/env bats

load helpers

@test "dropletctl with no args prints usage" {
    run "$DROPLETCTL"
    [ "$status" -eq 0 ]
    [[ "$output" =~ "Usage:" ]] || [[ "$output" =~ "dropletctl" ]]
}
EOF
  • Step 5: Verify bats picks up the suite (test will fail — dropletctl doesn't exist yet)

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 1 test ran, FAIL with "DROPLETCTL: command not found" or "permission denied". This proves the harness loads.

  • Step 6: Create the dropletctl skeleton so the smoke test passes
mkdir -p packages/secubox-droplet/sbin
cat > packages/secubox-droplet/sbin/dropletctl <<'EOF'
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.

# SecuBox-Deb :: dropletctl — droplet content publisher (port from OpenWrt)
set -euo pipefail

readonly MODULE="dropletctl"
readonly VERSION="1.1.0"

case "${1:-}" in
    "" | -h | --help)
        cat <<USAGE
Droplet Publisher — One-drop static-content publishing
Usage: dropletctl <command> [args]

Commands:
  publish <file> <name> [domain]   Publish HTML/ZIP/tarball as a site
  list                             List published droplets
  remove <name>                    Remove a droplet
  rename <old> <new>               Rename a droplet
USAGE
        exit 0
        ;;
    *)
        echo "[ERROR] unknown subcommand: $1" >&2
        exit 1
        ;;
esac
EOF
chmod +x packages/secubox-droplet/sbin/dropletctl
  • Step 7: Run bats to confirm green

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 1 test, 0 failures

  • Step 8: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/helpers.bash \
        packages/secubox-droplet/tests/test_dropletctl.bats \
        packages/secubox-droplet/tests/fixtures/bin/metablogizerctl \
        packages/secubox-droplet/tests/fixtures/bin/logger
git commit -m "feat(droplet): scaffold dropletctl CLI + bats harness (ref #196)"

Task 2: publish argument validation (TDD: 3 error cases)

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add 3 failing tests for argument validation

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "publish with no args exits 1 with Usage" {
    run "$DROPLETCTL" publish
    [ "$status" -eq 1 ]
    [[ "$output" =~ "Usage" ]]
}

@test "publish with missing file exits 1" {
    run "$DROPLETCTL" publish "$TMP/does-not-exist.html" foo
    [ "$status" -eq 1 ]
    [[ "$output" =~ "not found" ]]
}

@test "publish with unsupported extension exits 1" {
    : > "$TMP/bad.xyz"
    run "$DROPLETCTL" publish "$TMP/bad.xyz" foo
    [ "$status" -eq 1 ]
    [[ "$output" =~ "Unsupported file type" ]]
}
  • Step 2: Run tests to verify they fail

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 4 tests, 3 failures (the new ones — dropletctl returns "unknown subcommand").

  • Step 3: Implement publish dispatch + validation

Replace the case block in packages/secubox-droplet/sbin/dropletctl with:

case "${1:-}" in
    "" | -h | --help)
        cat <<USAGE
Droplet Publisher — One-drop static-content publishing
Usage: dropletctl <command> [args]

Commands:
  publish <file> <name> [domain]   Publish HTML/ZIP/tarball as a site
  list                             List published droplets
  remove <name>                    Remove a droplet
  rename <old> <new>               Rename a droplet
USAGE
        exit 0
        ;;
    publish)
        shift
        cmd_publish "$@"
        ;;
    *)
        echo "[ERROR] unknown subcommand: $1" >&2
        exit 1
        ;;
esac

And add this function above the case block:

cmd_publish() {
    local file="${1:-}"
    local name="${2:-}"
    local domain="${3:-}"

    if [[ -z "$file" ]] || [[ -z "$name" ]]; then
        echo "[ERROR] Usage: dropletctl publish <file> <name> [domain]" >&2
        exit 1
    fi
    if [[ ! -e "$file" ]]; then
        echo "[ERROR] not found: $file" >&2
        exit 1
    fi

    local ext="${file##*.}"
    ext="${ext,,}"  # bash 4 lowercase
    # Handle .tar.gz double-extension
    if [[ "$file" == *.tar.gz ]]; then ext="tar.gz"; fi

    case "$ext" in
        html|htm|zip|tgz|tar.gz)
            : ;;
        *)
            echo "[ERROR] Unsupported file type: .$ext (expected .html .htm .zip .tar.gz .tgz)" >&2
            exit 1
            ;;
    esac

    # Stub — actual staging arrives in later tasks.
    echo "[OK] (stub) would publish $file as $name"
    echo "${name}.${domain:-gk2.secubox.in}"
}
  • Step 4: Run tests to verify the 3 new ones pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 4 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): publish arg validation (3 error cases) (ref #196)"

Task 3: name sanitization + single HTML happy path

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add 2 failing tests for HTML publish + name sanitization

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "publish a single HTML stages it and delegates to metablogizerctl" {
    make_html "$TMP/page.html"

    run "$DROPLETCTL" publish "$TMP/page.html" mysite mydomain.test
    [ "$status" -eq 0 ]
    [[ "$output" =~ "[OK]" ]]
    # API parses last stdout line as vhost.
    last_line="$(printf '%s\n' "$output" | tail -1)"
    [ "$last_line" = "mysite.mydomain.test" ]
    # Docroot has the staged file.
    [ -f "$SITES_DIR/mysite/index.html" ]
    grep -q "fixture" "$SITES_DIR/mysite/index.html"
    # Delegate was called with the right args.
    grep -q "metablogizerctl site publish mysite" "$STUB_LOG"
}

@test "publish sanitizes uppercase + special chars in name" {
    make_html "$TMP/page.html"

    run "$DROPLETCTL" publish "$TMP/page.html" "MyCool Site!" mydomain.test
    [ "$status" -eq 0 ]
    last_line="$(printf '%s\n' "$output" | tail -1)"
    [ "$last_line" = "mycool_site_.mydomain.test" ]
    [ -d "$SITES_DIR/mycool_site_" ]
}
  • Step 2: Run tests to verify they fail

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 6 tests, 2 failures (the HTML stage / delegate / docroot checks fail because cmd_publish is still a stub).

  • Step 3: Implement name sanitization + HTML staging + delegate call

Replace cmd_publish in packages/secubox-droplet/sbin/dropletctl with the full implementation:

# Environment overrides for tests (default to production paths if unset).
: "${SITES_DIR:=/srv/metablogizer/sites}"
: "${TOML_PATH:=/etc/secubox/droplet.toml}"
: "${LOG_TAG:=droplet}"

log_info()  { logger -t "$LOG_TAG" -p user.info  "$*" 2>/dev/null || true; echo "[INFO] $*"; }
log_error() { logger -t "$LOG_TAG" -p user.error "$*" 2>/dev/null || true; echo "[ERROR] $*" >&2; }
log_ok()    { echo "[OK] $*"; }

# Lowercase + replace non-[a-z0-9_-] with '_'. Empty result -> caller errors.
sanitize_name() {
    printf '%s' "$1" | awk '{print tolower($0)}' | sed 's/[^a-z0-9_-]/_/g'
}

# Read default_domain from droplet.toml. Falls back to gk2.secubox.in.
default_domain() {
    [[ -r "$TOML_PATH" ]] || { echo "gk2.secubox.in"; return; }
    local v
    v=$(grep -E '^default_domain\s*=' "$TOML_PATH" 2>/dev/null \
        | head -1 | sed -E 's/^default_domain\s*=\s*"?([^"]*)"?\s*$/\1/')
    echo "${v:-gk2.secubox.in}"
}

# Stage <src> into a fresh temp dir. Echoes the staging dir path.
stage_content() {
    local src="$1"
    local staging
    staging="$(mktemp -d /tmp/droplet.XXXXXX)"

    local ext="${src##*.}"
    ext="${ext,,}"
    if [[ "$src" == *.tar.gz ]]; then ext="tar.gz"; fi

    case "$ext" in
        html|htm)
            cp "$src" "$staging/index.html"
            ;;
        zip|tgz|tar.gz)
            log_error "extraction not implemented yet"
            rm -rf "$staging"
            exit 3
            ;;
    esac

    echo "$staging"
}

cmd_publish() {
    local file="${1:-}"
    local name_raw="${2:-}"
    local domain="${3:-$(default_domain)}"

    if [[ -z "$file" ]] || [[ -z "$name_raw" ]]; then
        echo "[ERROR] Usage: dropletctl publish <file> <name> [domain]" >&2
        exit 1
    fi
    if [[ ! -e "$file" ]]; then
        echo "[ERROR] not found: $file" >&2
        exit 1
    fi

    local ext="${file##*.}"; ext="${ext,,}"
    [[ "$file" == *.tar.gz ]] && ext="tar.gz"
    case "$ext" in
        html|htm|zip|tgz|tar.gz) : ;;
        *)
            echo "[ERROR] Unsupported file type: .$ext (expected .html .htm .zip .tar.gz .tgz)" >&2
            exit 1
            ;;
    esac

    local name; name="$(sanitize_name "$name_raw")"
    if [[ -z "$name" ]]; then
        echo "[ERROR] Invalid name '$name_raw'" >&2
        exit 2
    fi

    local vhost="${name}.${domain}"
    local staging; staging="$(stage_content "$file")"
    trap 'rm -rf "$staging"' EXIT

    log_info "Publishing: $file as $vhost"

    # Permissions: 644 files, 755 dirs.
    find "$staging" -type f -exec chmod 644 {} +
    find "$staging" -type d -exec chmod 755 {} +

    # Deploy to /srv/metablogizer/sites/<name>/ (idempotent overwrite).
    mkdir -p "$SITES_DIR/$name"
    rsync -a --delete "$staging"/ "$SITES_DIR/$name"/

    # Upsert TOML entry. Section format: [sites.<name>] domain="<d>" type="static".
    upsert_toml_site "$name" "$domain" "static"

    # Delegate the HTTP-facing work.
    if ! command -v metablogizerctl >/dev/null 2>&1; then
        log_error "metablogizerctl not found — install secubox-metablogizer"
        exit 4
    fi
    if ! metablogizerctl site publish "$name"; then
        log_error "metablogizerctl failed for $name"
        exit 5
    fi

    log_ok "Published: https://$vhost/"
    echo "$vhost"
}

# Upsert [sites.<name>] section. Uses python3 for safe TOML rewrite.
upsert_toml_site() {
    local name="$1" domain="$2" type="$3"
    python3 - "$TOML_PATH" "$name" "$domain" "$type" <<'PY'
import sys, re, os, tempfile

path, name, domain, type_ = sys.argv[1:]
section = f"[sites.{name}]"
new_block = f'{section}\ndomain = "{domain}"\ntype = "{type_}"\n'

if os.path.exists(path):
    with open(path) as f:
        content = f.read()
else:
    content = ""

# Match the section + its body up to the next [section] or EOF.
pattern = re.compile(
    rf'^{re.escape(section)}\s*\n(?:(?!^\[).*\n?)*',
    flags=re.MULTILINE,
)
if pattern.search(content):
    new_content = pattern.sub(new_block, content)
else:
    if content and not content.endswith('\n'):
        content += '\n'
    if content and not content.endswith('\n\n'):
        content += '\n'
    new_content = content + new_block

# Atomic write.
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.', prefix='.droplet.', suffix='.toml')
os.write(fd, new_content.encode())
os.close(fd)
os.replace(tmp, path)
PY
}
  • Step 4: Run tests to verify all 6 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 6 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): name sanitization + HTML staging + metablogizerctl delegation (ref #196)"

Task 4: ZIP extraction with nested-dir unwrap

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing test for ZIP publish with nested top-level dir

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "publish a zip with one nested top dir unwraps it" {
    make_zip_nested "$TMP/site.zip"

    run "$DROPLETCTL" publish "$TMP/site.zip" zipsite mydomain.test
    [ "$status" -eq 0 ]
    # Unwrapped: NOT $SITES_DIR/zipsite/site/index.html, but directly:
    [ -f "$SITES_DIR/zipsite/index.html" ]
    [ -f "$SITES_DIR/zipsite/style.css" ]
    # Nested wrapper dir is gone.
    [ ! -d "$SITES_DIR/zipsite/site" ]
}
  • Step 2: Run to verify failure

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 7 tests, 1 failure (the ZIP path hits the stubbed "extraction not implemented" exit 3).

  • Step 3: Implement ZIP extraction with unwrap

Replace the stage_content function in packages/secubox-droplet/sbin/dropletctl with:

# Stage <src> into a fresh temp dir. Echoes the staging dir path.
stage_content() {
    local src="$1"
    local staging
    staging="$(mktemp -d /tmp/droplet.XXXXXX)"

    local ext="${src##*.}"
    ext="${ext,,}"
    if [[ "$src" == *.tar.gz ]]; then ext="tar.gz"; fi

    case "$ext" in
        html|htm)
            cp "$src" "$staging/index.html"
            ;;
        zip)
            if ! unzip -q "$src" -d "$staging"; then
                log_error "Failed to extract zip: $src"
                rm -rf "$staging"
                exit 3
            fi
            unwrap_single_nested "$staging"
            ;;
        tgz|tar.gz)
            log_error "tarball extraction not implemented yet"
            rm -rf "$staging"
            exit 3
            ;;
    esac

    echo "$staging"
}

# If $1 contains exactly one entry and it's a directory, move its contents up.
unwrap_single_nested() {
    local dir="$1"
    local entries=("$dir"/*)
    if [[ ${#entries[@]} -eq 1 ]] && [[ -d "${entries[0]}" ]]; then
        local nested="${entries[0]}"
        # Use a temp move to avoid name collision when nested basename matches dir.
        local stash; stash="$(mktemp -d "$dir/.unwrap.XXXXXX")"
        mv "$nested"/* "$nested"/.[!.]* "$stash"/ 2>/dev/null || true
        rmdir "$nested"
        mv "$stash"/* "$stash"/.[!.]* "$dir"/ 2>/dev/null || true
        rmdir "$stash"
    fi
}
  • Step 4: Run tests to confirm all 7 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 7 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): ZIP extraction with single-nested-dir unwrap (ref #196)"

Task 5: tarball extraction (.tar.gz / .tgz) + plain-directory input

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing tests for tarball publish AND directory input

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "publish a tarball with one nested top dir unwraps it" {
    make_tarball_nested "$TMP/site.tar.gz"

    run "$DROPLETCTL" publish "$TMP/site.tar.gz" tarsite mydomain.test
    [ "$status" -eq 0 ]
    [ -f "$SITES_DIR/tarsite/index.html" ]
    [ -f "$SITES_DIR/tarsite/style.css" ]
    [ ! -d "$SITES_DIR/tarsite/site" ]
}

@test "publish a plain directory copies its tree verbatim" {
    mkdir -p "$TMP/site_src"
    make_html "$TMP/site_src/index.html"
    printf 'body { color: blue; }\n' > "$TMP/site_src/style.css"

    run "$DROPLETCTL" publish "$TMP/site_src" dirsite mydomain.test
    [ "$status" -eq 0 ]
    [ -f "$SITES_DIR/dirsite/index.html" ]
    [ -f "$SITES_DIR/dirsite/style.css" ]
    grep -q "fixture" "$SITES_DIR/dirsite/index.html"
}
  • Step 2: Run to verify both new tests fail

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 9 tests, 2 failures (tarball still stubbed; directory hits the extension validation as "Unsupported file type: .site_src").

  • Step 3: Wire tarball extraction + add directory branch in cmd_publish

(a) In packages/secubox-droplet/sbin/dropletctl, replace the tgz|tar.gz) branch inside stage_content:

        tgz|tar.gz)
            if ! tar -xzf "$src" -C "$staging"; then
                log_error "Failed to extract tarball: $src"
                rm -rf "$staging"
                exit 3
            fi
            unwrap_single_nested "$staging"
            ;;
        dir)
            # Caller passed a directory — rsync its contents verbatim.
            rsync -a "$src"/ "$staging"/
            ;;

(b) Adjust the extension-detection block in cmd_publish so that a directory short-circuits the .ext check. Replace the extension/case block in cmd_publish with:

    # If the path is a directory, skip extension validation entirely.
    local ext
    if [[ -d "$file" ]]; then
        ext="dir"
    else
        ext="${file##*.}"; ext="${ext,,}"
        [[ "$file" == *.tar.gz ]] && ext="tar.gz"
        case "$ext" in
            html|htm|zip|tgz|tar.gz) : ;;
            *)
                echo "[ERROR] Unsupported file type: .$ext (expected .html .htm .zip .tar.gz .tgz or directory)" >&2
                exit 1
                ;;
        esac
    fi

And in stage_content, the dispatch needs to also accept the dir synthetic ext, which the patch in (a) already wires. Confirm the stage_content case "$ext" in now reads:

    case "$ext" in
        html|htm) ... ;;
        zip)      ... ;;
        tgz|tar.gz) ... ;;
        dir)      ... ;;
    esac

(c) stage_content's ext-detection block currently re-derives the extension from the filename. Update it so a directory also resolves to ext=dir:

    local ext
    if [[ -d "$src" ]]; then
        ext="dir"
    else
        ext="${src##*.}"; ext="${ext,,}"
        [[ "$src" == *.tar.gz ]] && ext="tar.gz"
    fi
  • Step 4: Run tests to confirm all 9 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 9 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): tarball + plain-directory input (ref #196)"

Task 6: idempotent re-publish (overwrite + no TOML duplicates)

Files:

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing test for double-publish idempotency

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "publish twice for same name overwrites docroot, no TOML dupes" {
    make_html "$TMP/v1.html"
    make_html "$TMP/v2.html"
    # Make v2 distinguishable.
    printf '<!doctype html><html><body><h2>v2</h2></body></html>\n' > "$TMP/v2.html"

    run "$DROPLETCTL" publish "$TMP/v1.html" rev mydomain.test
    [ "$status" -eq 0 ]
    grep -q "fixture" "$SITES_DIR/rev/index.html"

    run "$DROPLETCTL" publish "$TMP/v2.html" rev mydomain.test
    [ "$status" -eq 0 ]
    # v2 overwrote v1.
    grep -q "v2" "$SITES_DIR/rev/index.html"
    ! grep -q "fixture" "$SITES_DIR/rev/index.html"
    # Exactly one [sites.rev] block in the TOML.
    count="$(grep -c '^\[sites\.rev\]' "$TOML_PATH")"
    [ "$count" = "1" ]
}
  • Step 2: Run to verify the test passes already

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 10 tests, 0 failures. The upsert_toml_site from Task 3 already handles the idempotency via its regex substitution + rsync --delete handles the overwrite. This test pins the behavior so future refactors don't regress.

  • Step 3: Commit
git add packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "test(droplet): pin idempotent re-publish behavior (ref #196)"

Task 7: remove subcommand

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing test for remove after publish

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "remove deletes docroot, TOML entry, and calls metablogizerctl unpublish + delete" {
    make_html "$TMP/page.html"
    run "$DROPLETCTL" publish "$TMP/page.html" doomed mydomain.test
    [ "$status" -eq 0 ]
    [ -d "$SITES_DIR/doomed" ]
    grep -q '^\[sites\.doomed\]' "$TOML_PATH"

    # Reset stub log so we only see remove's calls.
    : > "$STUB_LOG"
    run "$DROPLETCTL" remove doomed
    [ "$status" -eq 0 ]
    [[ "$output" =~ "[OK]" ]]
    [ ! -d "$SITES_DIR/doomed" ]
    ! grep -q '^\[sites\.doomed\]' "$TOML_PATH"
    grep -q "metablogizerctl site unpublish doomed" "$STUB_LOG"
    grep -q "metablogizerctl site delete doomed" "$STUB_LOG"
}
  • Step 2: Run to verify failure

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 11 tests, 1 failure (unknown subcommand: remove).

  • Step 3: Implement cmd_remove + TOML delete helper + dispatch

In packages/secubox-droplet/sbin/dropletctl, add this function above the case block:

cmd_remove() {
    local name_raw="${1:-}"
    if [[ -z "$name_raw" ]]; then
        echo "[ERROR] Usage: dropletctl remove <name>" >&2
        exit 1
    fi
    local name; name="$(sanitize_name "$name_raw")"
    if [[ -z "$name" ]]; then
        echo "[ERROR] Invalid name '$name_raw'" >&2
        exit 2
    fi

    if ! command -v metablogizerctl >/dev/null 2>&1; then
        log_error "metablogizerctl not found — install secubox-metablogizer"
        exit 4
    fi
    metablogizerctl site unpublish "$name" || true
    metablogizerctl site delete "$name"    || true

    rm -rf "$SITES_DIR/$name"
    remove_toml_site "$name"
    log_ok "Removed: $name"
}

# Drop the [sites.<name>] block from droplet.toml. No-op if not present.
remove_toml_site() {
    local name="$1"
    [[ -e "$TOML_PATH" ]] || return 0
    python3 - "$TOML_PATH" "$name" <<'PY'
import sys, re, os, tempfile
path, name = sys.argv[1:]
with open(path) as f:
    content = f.read()
pattern = re.compile(
    rf'^\[sites\.{re.escape(name)}\]\s*\n(?:(?!^\[).*\n?)*',
    flags=re.MULTILINE,
)
new = pattern.sub('', content)
if new == content:
    sys.exit(0)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.', prefix='.droplet.', suffix='.toml')
os.write(fd, new.encode())
os.close(fd)
os.replace(tmp, path)
PY
}

Add remove) to the dispatch case:

    remove)
        shift
        cmd_remove "$@"
        ;;
  • Step 4: Run tests to confirm all 11 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 11 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): remove subcommand + TOML section delete (ref #196)"

Task 8: rename subcommand

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing test for rename

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "rename moves docroot, swaps TOML entry, calls delete+publish on delegate" {
    make_html "$TMP/page.html"
    run "$DROPLETCTL" publish "$TMP/page.html" oldname mydomain.test
    [ "$status" -eq 0 ]
    [ -d "$SITES_DIR/oldname" ]

    : > "$STUB_LOG"
    run "$DROPLETCTL" rename oldname newname
    [ "$status" -eq 0 ]
    [[ "$output" =~ "[OK]" ]]
    [ ! -d "$SITES_DIR/oldname" ]
    [ -d "$SITES_DIR/newname" ]
    [ -f "$SITES_DIR/newname/index.html" ]
    ! grep -q '^\[sites\.oldname\]' "$TOML_PATH"
    grep -q '^\[sites\.newname\]' "$TOML_PATH"
    # Delegate called: delete old, then publish new.
    grep -q "metablogizerctl site delete oldname" "$STUB_LOG"
    grep -q "metablogizerctl site publish newname" "$STUB_LOG"
}
  • Step 2: Run to verify failure

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 12 tests, 1 failure (unknown subcommand: rename).

  • Step 3: Implement cmd_rename + dispatch

In packages/secubox-droplet/sbin/dropletctl, add this function above the case block:

cmd_rename() {
    local old_raw="${1:-}" new_raw="${2:-}"
    if [[ -z "$old_raw" ]] || [[ -z "$new_raw" ]]; then
        echo "[ERROR] Usage: dropletctl rename <old> <new>" >&2
        exit 1
    fi
    local old new
    old="$(sanitize_name "$old_raw")"
    new="$(sanitize_name "$new_raw")"
    if [[ -z "$old" ]] || [[ -z "$new" ]]; then
        echo "[ERROR] Invalid name(s): '$old_raw' or '$new_raw'" >&2
        exit 2
    fi
    if [[ ! -d "$SITES_DIR/$old" ]]; then
        echo "[ERROR] not found: $old" >&2
        exit 1
    fi

    # Read old domain so the new site keeps the same default unless overridden.
    local domain
    domain=$(grep -A2 "^\[sites\.$old\]" "$TOML_PATH" 2>/dev/null \
        | grep -E '^domain\s*=' | head -1 \
        | sed -E 's/^domain\s*=\s*"?([^"]*)"?\s*$/\1/')
    domain="${domain:-$(default_domain)}"

    if ! command -v metablogizerctl >/dev/null 2>&1; then
        log_error "metablogizerctl not found — install secubox-metablogizer"
        exit 4
    fi

    mv "$SITES_DIR/$old" "$SITES_DIR/$new"
    remove_toml_site "$old"
    # Compute new domain by replacing the leading <old>. prefix if present, else use original.
    local new_domain="$domain"
    if [[ "$domain" == "${old}."* ]]; then
        new_domain="${new}.${domain#*.}"
    fi
    upsert_toml_site "$new" "$new_domain" "static"

    metablogizerctl site delete "$old"  || true
    metablogizerctl site publish "$new" || {
        log_error "metablogizerctl failed for $new"
        exit 5
    }
    log_ok "Renamed: $old -> $new"
}

Add rename) to dispatch:

    rename)
        shift
        cmd_rename "$@"
        ;;
  • Step 4: Run tests to confirm all 12 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 12 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): rename subcommand (ref #196)"

Task 9: list subcommand

Files:

  • Modify: packages/secubox-droplet/sbin/dropletctl

  • Modify: packages/secubox-droplet/tests/test_dropletctl.bats

  • Step 1: Add failing test for list

Append to packages/secubox-droplet/tests/test_dropletctl.bats:

@test "list prints one row per [sites.<name>] in droplet.toml" {
    make_html "$TMP/page.html"
    run "$DROPLETCTL" publish "$TMP/page.html" first mydomain.test
    [ "$status" -eq 0 ]
    run "$DROPLETCTL" publish "$TMP/page.html" second mydomain.test
    [ "$status" -eq 0 ]

    run "$DROPLETCTL" list
    [ "$status" -eq 0 ]
    [[ "$output" =~ "first" ]]
    [[ "$output" =~ "first.mydomain.test" ]]
    [[ "$output" =~ "second" ]]
    [[ "$output" =~ "second.mydomain.test" ]]
}
  • Step 2: Run to verify failure

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 13 tests, 1 failure (unknown subcommand: list).

  • Step 3: Implement cmd_list + dispatch

In packages/secubox-droplet/sbin/dropletctl, add this function above the case block:

cmd_list() {
    [[ -r "$TOML_PATH" ]] || { echo "(no droplets)"; return 0; }
    python3 - "$TOML_PATH" <<'PY'
import sys, re
path = sys.argv[1]
with open(path) as f:
    content = f.read()
# Capture [sites.<name>] then the immediate `domain = "..."` line in its block.
section_re = re.compile(r'^\[sites\.([^\]]+)\]\s*\n((?:(?!^\[).*\n?)*)', re.MULTILINE)
for m in section_re.finditer(content):
    name = m.group(1)
    body = m.group(2)
    dm = re.search(r'^domain\s*=\s*"?([^"\n]+)"?', body, re.MULTILINE)
    domain = dm.group(1).strip() if dm else "?"
    print(f"{name:<30s} [ON]  https://{domain}/")
PY
}

Add list) to dispatch:

    list)
        cmd_list
        ;;
  • Step 4: Run tests to confirm all 13 pass

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 13 tests, 0 failures

  • Step 5: Commit
git add packages/secubox-droplet/sbin/dropletctl \
        packages/secubox-droplet/tests/test_dropletctl.bats
git commit -m "feat(droplet): list subcommand (ref #196)"

Task 10: Debian packaging (rules + control + changelog)

Files:

  • Modify: packages/secubox-droplet/debian/rules

  • Modify: packages/secubox-droplet/debian/control

  • Modify: packages/secubox-droplet/debian/changelog

  • Step 1: Add the install block in debian/rules

Append to the existing override_dh_auto_install: target (after the last cp line):

	# Install the dropletctl CLI to /usr/sbin/ (new in 1.1.0).
	install -d debian/secubox-droplet/usr/sbin
	install -m 0755 sbin/dropletctl debian/secubox-droplet/usr/sbin/dropletctl
  • Step 2: Add the metablogizer dependency in debian/control

Replace the Depends: line:

Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, python3-aiofiles, secubox-metablogizer (>= 1.1)
  • Step 3: Bump the changelog

Prepend to packages/secubox-droplet/debian/changelog:

secubox-droplet (1.1.0-1~bookworm1) bookworm; urgency=medium

  * Add dropletctl CLI at /usr/sbin/dropletctl (port from OpenWrt
    secubox-app-droplet 1.0.0). Subcommands: publish, list, remove,
    rename. Static-only (HTML / ZIP / tarball). Delegates HTTP-facing
    work (nginx vhost, HAProxy ACL, mitmproxy route) to
    metablogizerctl site publish.
  * Depends: secubox-metablogizer (>= 1.1) — runtime dependency for
    publish/unpublish/delete.
  * Closes: #196

 -- Gerald KERMA <devel@cybermind.fr>  Mon, 18 May 2026 09:00:00 +0200

(The trailing blank line is required — preserve the existing changelog below it.)

  • Step 4: Sanity-check the build manifest

Run: cd packages/secubox-droplet && dpkg-buildpackage -us -uc -b 2>&1 | tail -20 Expected: build succeeds; the manifest line lists usr/sbin/dropletctl (search the build output for dropletctl).

If the build host doesn't have the chroot, just lint: Run: cd packages/secubox-droplet && dpkg-checkbuilddeps Expected: no missing deps; if any, install them.

  • Step 5: Commit
git add packages/secubox-droplet/debian/rules \
        packages/secubox-droplet/debian/control \
        packages/secubox-droplet/debian/changelog
git commit -m "build(droplet): ship dropletctl + Depends: metablogizer, bump to 1.1.0 (ref #196)"

Task 11: lint + final test sweep

Files:

  • Touch: nothing — pure verification + commit gate

  • Step 1: bash syntax check

Run: bash -n packages/secubox-droplet/sbin/dropletctl Expected: exit 0, no output.

  • Step 2: shellcheck

Run: shellcheck packages/secubox-droplet/sbin/dropletctl packages/secubox-droplet/tests/fixtures/bin/metablogizerctl packages/secubox-droplet/tests/fixtures/bin/logger Expected: exit 0. If shellcheck flags items: fix them inline and re-run. Common acceptable disables: # shellcheck disable=SC2155 for local x="$(…)" patterns where capturing exit code isn't needed.

  • Step 3: full bats sweep

Run: cd packages/secubox-droplet && bats tests/test_dropletctl.bats Expected: 13 tests, 0 failures

  • Step 4: confirm git tree is clean

Run: git status -s Expected: empty output (no uncommitted changes).

  • Step 5: commit a trivial CHANGELOG note marker if shellcheck required any disable comments

(Skip if Step 2 was clean.) Otherwise:

git add packages/secubox-droplet/sbin/dropletctl
git commit -m "chore(droplet): shellcheck-clean dropletctl (ref #196)"
  • Step 6: Final sanity — print the diff stat

Run: git diff --stat origin/master..HEAD Expected: ~3 source files (dropletctl + helpers.bash + test_dropletctl.bats), 2 stub bin files, 3 debian/ files modified. Total around +500 / 10 lines.


Hardware deploy (deferred, post-merge)

Not part of this plan. Once PR merges to master, deploy on gk2 (192.168.1.200) per spec §"Deploy path on gk2":

cd packages/secubox-droplet
dpkg-buildpackage -a arm64 --host-arch arm64 -us -uc -b
scp ../secubox-droplet_1.1.0-1~bookworm1_all.deb root@admin.gk2.secubox.in:/tmp/
ssh root@admin.gk2.secubox.in 'apt install /tmp/secubox-droplet_1.1.0-1~bookworm1_all.deb'

# Smoke test:
ssh root@admin.gk2.secubox.in 'which dropletctl'   # /usr/sbin/dropletctl
ssh root@admin.gk2.secubox.in 'echo "<h1>smoke</h1>" > /tmp/smoke.html && \
    dropletctl publish /tmp/smoke.html smoke gk2.secubox.in'
curl -sSI https://smoke.gk2.secubox.in/   # expect 200 OK
ssh root@admin.gk2.secubox.in 'dropletctl remove smoke'

If the live deploy uncovers issues, file a fix-followup against this branch before opening the PR for review.