Author: Mark Gardner

  • Porting from Perl to Go: Simplifying for Platform Engineering

    Porting from Perl to Go: Simplifying for Platform Engineering

    Rewriting a script for the Homebrew pack­age man­ag­er taught me how the Go pro­gram­ming lan­guage’s design choic­es align with platform-​ready tools.

    The problem with brew upgrade

    By default, the brew upgrade com­mand updates every for­mu­la (ter­mi­nal util­i­ty or library). It also updates every cask (GUI appli­ca­tion) it man­ages. All are upgrad­ed to the lat­est ver­sion — major, minor, and patch. That’s con­ve­nient when you want the newest fea­tures, but dis­rup­tive when you only want qui­et patch-​level fixes.

    Last week I solved this in Perl with brew-patch-upgrade.pl, a script that parsed brew upgrade​’s JSON out­put, com­pared seman­tic ver­sions, and upgrad­ed only when the patch num­ber changed. It worked, but it also remind­ed me how much Perl leans on implic­it struc­tures and run­time flexibility.

    This week I port­ed the script to Go, the lin­gua fran­ca of DevOps. The goal was­n’t fea­ture par­i­ty — it was to see how Go’s design choic­es map onto plat­form engi­neer­ing concerns.

    Why port to Go?

    • Portfolio prac­tice: I’m build­ing a body of work that demon­strates plat­form engi­neer­ing skills.
    • Operational focus: Go is wide­ly used for tool­ing in infra­struc­ture and cloud environments.
    • Learning by con­trast: Rewriting a work­ing Perl script in Go forces me to con­front dif­fer­ences in error han­dling, type safe­ty, and distribution.

    The journey

    Error handling philosophy

    Perl gave me try/​catch (exper­i­men­tal in the Perl v5.34.1 that ships with macOS, but since accept­ed into the lan­guage in v5.40). Go, famous­ly, does not. Instead, every func­tion returns an error explicitly.

    Perl:

    use v5.34;
    use warnings;
    use experimental qw(try);
    use Carp;
    use autodie;
    
    ...
    
    try {
      system 'brew', 'upgrade', $name;
      $result = 'upgraded';
    }
    catch ($e) {
      $result = 'failed';
      carp $e;
    }

    Go:

    package main
    
    import (
      "os/exec"
      "log"
    )
    
    ...
    
    cmd := exec.Command("brew", "upgrade", name)
    if output, err := cmd.CombinedOutput(); err != nil {
      log.Printf("failed to upgrade %s: %v\n%s",
        name,
        err,
        output)
    }

    The Go ver­sion is nois­i­er, but it forces explic­it deci­sions. That’s a fea­ture in pro­duc­tion tool­ing: no silent failures.

    Dependency management

    • Perl: cpanfile + CPAN mod­ules. Distribution means ​“install Perl (if it’s not already), install mod­ules, run script.” Tools like carton and the cpan or cpanm com­mands help auto­mate this. Additionally, one can use fur­ther tool­ing like fatpack and pp to build more self-​contained pack­ages. But those are nei­ther com­mon nor (except for cpan) dis­trib­uted with Perl.
    • Go: go.mod + go build. Distribution is a sin­gle (platform-​specific) binary.

    For oper­a­tional tools, that’s a mas­sive sim­pli­fi­ca­tion. No run­time inter­preter, no depen­den­cy dance.

    Type safety

    Perl let me parse JSON into hashrefs and trust the keys exist. Go required a struct:

    type Formula struct {
      Name              string   `json:"name"`
      CurrentVersion    string   `json:"current_version"`
      InstalledVersions []string `json:"installed_versions"`
    }

    The com­pil­er enforces assump­tions that Perl left implic­it. That fric­tion is valu­able — it sur­faces errors early.

    Binary distribution

    This is where Go shines. Instead of telling col­leagues ​“install Perl v5.34 and CPAN mod­ules,” I can hand them a bina­ry. No need to wor­ry about script­ing run­time envi­ron­ments — just grab the right file for your system.

    Available on the release page. Download, run, done.

    Semantic versioning logic

    In Perl, I man­u­al­ly com­pared arrays of ver­sion num­bers. In Go, I import­ed golang.org/x/mod/semver:

    import (
      golang.org/x/mod/semver
    )
    
    ...
    
    if semver.MajorMinor(toSemver(formula.InstalledVersions[0])) !=
      semver.MajorMinor(toSemver(formula.CurrentVersion)) {
      log.Printf("%s is not a patch upgrade", formula.Name)
      results.skipped++
      continue
    }

    Cleaner, more leg­i­ble, and less error-​prone. The library encodes the con­ven­tion, so I don’t have to.

    Deliberate simplification

    I did­n’t port every fea­ture. Logging adapters, sig­nal han­dlers, and edge-​case diag­nos­tics remained in Perl. The Go ver­sion focus­es on the core log­ic: parse JSON, com­pare ver­sions, run upgrades. That restraint was inten­tion­al — I want­ed to learn Go’s idioms, not repli­cate every Perl flourish.

    Platform engineering insights

    Three lessons stood out:

    1. Binary dis­tri­b­u­tion mat­ters. Operational tools should be instal­lable with a sin­gle copy step. Go makes that trivial.
    2. Semantic ver­sion­ing is an oper­a­tional prac­tice. It’s not just a con­ven­tion for library authors — it’s a con­tract that tool­ing can enforce.
    3. Go’s design aligns with plat­form needs. Explicit errors, type safe­ty, and sta­t­ic bina­ries all reduce sur­pris­es in production.

    Bringing it home

    This isn’t a ​“Perl vs. Go” sto­ry. It’s a sto­ry about delib­er­ate sim­pli­fi­ca­tion, tak­ing a work­ing Perl script and recast­ing it in Go. The aim is to see how the lan­guage’s choic­es shape a solu­tion to the same problem.

    The result is homebrew-semver-guard v0.1.0, a small but stur­dy tool. It’s not feature-​finished, but it’s production-​ready in the ways that matter.

    Next up: I’m con­sid­er­ing more Go tools, maybe even Kubernetes for ser­vices on my home serv­er. This port was prac­tice, an arti­fact demon­strat­ing plat­form engi­neer­ing in action.


    Links

  • Patch-​Perfect: Smarter Homebrew Upgrades on macOS

    Patch-​Perfect: Smarter Homebrew Upgrades on macOS

    This is a sto­ry about patches.

    At first, I just want­ed Homebrew to behave a lit­tle more polite­ly. Formulae should upgrade only on patch changes, with­out being dragged through minor and major bumps. That itch became a small(ish) Perl script, brew-patch-upgrade.pl.

    Along the way, I dis­cov­ered anoth­er patch was need­ed. My own log­ging adapter, Log::Any::Adapter::MacOS::OSLog, was­n’t build­ing and installing its bun­dle cor­rect­ly. Before the script can shine in the Mac Console app, I had to fix the adapter itself.

    What fol­lows is how those two threads came togeth­er. One is a tool that keeps Homebrew upgrades patch-​perfect. The oth­er is a log­ging adapter that final­ly behaves as a first-​class Perl module.

    What is Homebrew?

    Not to be con­fused with perlbrew, Homebrew is a free and open-​source pack­age man­ag­er for macOS (and Linux). It makes it easy to install and update soft­ware from the com­mand line. You don’t have to hunt down installers on web­sites. Instead, you can type com­mands like brew install wget. Homebrew will then fetch, build, and link the tool into place.

    Homebrew ≠ Perlbrew

    Everything is orga­nized under /opt/homebrew (on Apple Silicon-​based Macs) or /usr/local (on Intel). Homebrew can even man­age both command‑line util­i­ties (“for­mu­lae”) and desk­top apps (“casks”).

    In short: it’s the miss­ing pack­age man­ag­er Apple nev­er shipped, and it’s become an essen­tial part of many devel­op­ers’ workflows.

    Homebrew makes it easy to stay up to date — some­times too easy. By default, brew upgrade jumps to the lat­est ver­sion of every­thing, even across minor and major releases.

    That’s fine when you want the newest fea­tures. Yet, it can be dis­rup­tive if all you real­ly need are the qui­et, patch-​level fixes.

    Scripted patch-​only upgrades

    My brew‑patch‑upgrade.pl takes a nar­row­er view: it pars­es brew outdated com­mand, com­pares seman­tic ver­sions, and upgrades only when the patch num­ber changes.

    That means:

    • 3.2.1 → 3.2.4 will be upgraded.
    • 3.2.1 → 3.3.0 will be skipped.
    • 3.2.1 → 4.0.0 will be skipped.

    Normally, the scrip­t’s out­put is com­pa­ra­ble to brew upgrade, stream­ing the famil­iar Homebrew out­put to your ter­mi­nal for any patch-​level updates while not­ing any skipped versions:

    % brew-patch-upgrade.pl
    Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0x7c075dc90)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0x7c0c1a180)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    ==> Upgrading 1 outdated package:
    zstd 1.5.6 -> 1.5.7
    ==> Fetching downloads for: zstd
    ==> Fetching zstd
    ==> Downloading https://ghcr.io/v2/homebrew/core/zstd/blobs/sha256:ddb0c145060bc2366ce5d58d95aa205bb15cb4c66948f20bb85e23fdb5eba7e9
    Already downloaded: /Users/mjg/Library/Caches/Homebrew/downloads/eb865576547e163ef6908f1e5762f4dc4d7fb548940f7b896ae12c0d5b202362--zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
    ==> Upgrading zstd
      1.5.6 -> 1.5.7
    ==> Pouring zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
    🍺  /opt/homebrew/Cellar/zstd/1.5.7: 32 files, 2.2MB
    ==> Running `brew cleanup zstd`...
    Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
    Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).
    Removing: /opt/homebrew/Cellar/zstd/1.5.6... (32 files, 2.1MB)
    Removing: /Users/mjg/Library/Caches/Homebrew/zstd_bottle_manifest--1.5.6... (11.9KB)
    Removing: /Users/mjg/Library/Caches/Homebrew/zstd--1.5.6... (753.9KB)
    ==> No outdated dependents to upgrade!

    But since I intend­ed this to run unat­tend­ed on a sched­ule, you can also tell it to log to a dif­fer­ent des­ti­na­tion using the envi­ron­ment vari­able LOG_ANY_DEFAULT_ADAPTER:

    % LOG_ANY_DEFAULT_ADAPTER=Stderr brew-patch-upgrade.pl
    using log adapter Log::Any::Adapter::Stderr
    outdated formulae: {casks => [],formulae => [{current_version => "12.0.0",installed_versions => ["11.5.1"],name => "harfbuzz",pinned => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),pinned_version => undef},{current_version => "3.10.0",installed_versions => ["3.9.0"],name => "woodpecker-cli",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef},{current_version => "1.5.7",installed_versions => ["1.5.6","1.5.7"],name => "zstd",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef}]}
    Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0xa1a01f510)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0xa1acbcf30)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Starting brew upgrade for zstd...
    ==> Upgrading 1 outdated package:
    zstd 1.5.6 -> 1.5.7
    Finished brew upgrade for zstd
    Summary: upgraded 1, skipped 2, failed 0

    That’s use­ful for debug­ging. But, the real pay­off comes when you send logs to a more sophis­ti­cat­ed adapter. A good exam­ple is my Log::Any::Adapter::MacOS::OSLog pack­age. It tags dif­fer­ent lev­els of log mes­sages in macOS’ uni­fied log­ging sys­tem. These mes­sages can then be fil­tered and searched in the Console util­i­ty app.

    The com­mand is like the Stderr exam­ple above:

    % LOG_ANY_DEFAULT_ADAPTER=MacOS::OSLog brew-patch-upgrade.pl

    And pro­duces results that can be viewed in Console:

    Screenshot of macOS' Console app showing messages from the com.phoenixtrap.brew-patch-upgrade subsystem

    Or using the macOS log show command:

    Screenshot of macOS' iTerm2 app showing messages from the com.phoenixtrap.brew-patch-upgrade subsystem of the unified log

    At the end, the script logs a sum­ma­ry of upgrad­ed, skipped, and failed for­mu­lae. On fail­ure, it exits non-​zero to make it easy to use in automation.

    Highlights from the script source code

    For those curi­ous about the inter­nals, here are a few high­lights from the source.

    Skipping major and minor version bumps

    use version;
    ...
    sub process_formula ($formula_ref) {
        my $name = $formula_ref->{name};
        my ( $current, $installed ) = map { defined and qv($_) } (
            $formula_ref->{current_version},
            $formula_ref->{installed_versions}->[0] );
        unless ( $current and $installed ) {
            $logger->debug(
                'no current and/or installed version detected for',
                $name );
            return 'skipped';
        }
        my $result = 'failed';    # default
        my @current   = $current->{version}->@*;
        my @installed = $installed->{version}->@*;
        #<<<
        unless (@current   >= 3
            and @installed >= 3
            and $installed[0] == $current[0]
            and $installed[1] == $current[1]
            and $installed[2] <  $current[2] )
        #>>>
        {
            carp "Skipping $name, needs manual review before upgrade";
            return 'skipped';
        }
        ...

    At the start, process_formula checks whether a for­mu­la from brew outdated has a valid seman­tic ver­sion. Only then does it com­pare patch numbers.

    To that end, it uses the core Perl version mod­ule’s qv func­tion. This pars­es the cur­rent and lat­est installed ver­sion, avoid­ing a com­pli­cat­ed reg­u­lar expres­sion. The result­ing ver­sion objects can be treat­ed as hash ref­er­ences with a version key. This key is itself a ref­er­ence to an array con­tain­ing the major, minor, and patch ver­sion num­bers. If the major or minor ver­sion num­bers dif­fer between installed and cur­rent, the script calls for a man­u­al review. Then it returns a ​“skipped” status.

    Setting up the loggers

    use Log::Any qw($logger);
    use Log::Any::Adapter;
    ...
    use constant {
        MAC_LOG_ADAPTER_CLASS => 'Log::Any::Adapter::MacOS::OSLog',
        MAC_LOG_SUBSYSTEM     => 'com.phoenixtrap.brew-patch-upgrade',
    };
    my %brew_log;
    if ( $ENV{LOG_ANY_DEFAULT_ADAPTER} ) {
        my $adapter = Log::Any::Adapter->get(__PACKAGE__);
        Log::Any::Adapter->set( q(+) . ref $adapter,
            subsystem => MAC_LOG_SUBSYSTEM )
            if $adapter->isa(MAC_LOG_ADAPTER_CLASS);
        $logger->debug( 'using log adapter', ref $adapter );
        $SIG{__WARN__} = sub ($message) { $logger->alert($message) };
        $SIG{__DIE__}  = sub ($message) { $logger->fatal($message) };
        foreach my $stdio (qw(stdout stderr)) {
            Log::Any::Adapter->set(
                { category => "brew.$stdio" },
                q(+) . ref $adapter,
                (   subsystem   => MAC_LOG_SUBSYSTEM,
                    os_category => "brew-$stdio",
                )x!!$adapter->isa(MAC_LOG_ADAPTER_CLASS),
            );
            $brew_log{$stdio}
                = Log::Any->get_logger( category => "brew.$stdio" );
        }
    }

    If LOG_ANY_DEFAULT_ADAPTER is set, the script cre­ates three log­gers: one for the pro­gram itself, one for brew.stdout, and one for brew.stderr. All three have log­ic to check if the default log­ger class is Log::Any::Adapter::MacOS::OSLog. If it is, an appro­pri­ate OS-​level log­ging cat­e­go­ry is set. A unique sub­sys­tem name for the script, ​“com.phoenixtrap.brew-patch-upgrade”, is also assigned.

    Unfortunately, while work­ing on this macOS-​specific log­ging code, I made a dis­cov­ery. I real­ized that I need­ed to fix my log adapter class with a patch of its own.

    OSLog revisited

    I was proud of my ear­li­er work on the MacOS::OSLog log adapter — a clever wrap­per around macOS’ log­ging func­tions using Perl for­eign func­tion inter­face (FFI). I even took a vic­to­ry lap, con­vert­ing the CPAN dis­tri­b­u­tion to use Dist::Zilla for plug­gable installer cre­ation and documentation.

    But out­side the pack­aged maclog script, the mod­ule did­n’t work. Oops.

    Once I tried using the MacOS::OSLog adapter in brew-patch-upgrade.pl, the cracks became obvi­ous. The bun­dle was­n’t built or installed cor­rect­ly. My _find_my_bundle hack was brit­tle — not a foun­da­tion to build on.

    The solu­tion was to stop fak­ing it and let the FFI::Platypus tool­chain do its job. Using Dist::Zilla::Plugin::MakeMaker::Awesome in my dist.ini file, I rewrote the build process to use FFI::Build::MM in the gen­er­at­ed Makefile.PL installer. This meant that the bun­dle would com­pile and install in the right place instead of rely­ing on my _find_my_bundle hack:

    [MakeMaker::Awesome]
    delimiter = |
    ...
    header = |use FFI::Build::MM;
    header = |my $fbmm = FFI::Build::MM->new();
    header = |my %fbmm_args = $fbmm->mm_args(
    header = |  DISTNAME     => 'Log-Any-Adapter-MacOS-OSLog',
    header = |  NAME         => 'Log::Any::Adapter::MacOS::OSLog',
    header = |  VERSION_FROM => 'lib/Log/Any/Adapter/MacOS/OSLog.pm',
    header = |);
    header = |$fbmm_args{CCFLAGS} .= ' -mmacosx-version-min=10.12';
    footer = |sub MY::postamble { $fbmm->mm_postamble }
    WriteMakefile_arg = %fbmm_args

    The loca­tion of the C source code as well as com­pi­la­tion flags moved to a sep­a­rate ffi/OSLog.fbx file, which FFI::Build::MM would infer the name of based on the argu­ments passed to its mm_args method.

    {
      source => [ 'ffi/OSLog.c' ],
      cflags => [ '-mmacosx-version-min=10.12' ],
      libs   => [ '-framework', 'OSLog' ],
    }

    I also updat­ed the C source code with an #include <ffi_platypus_bundle.h> line, giv­ing the result­ing code bun­dle a prop­er entry point that FFI::Platypus could recognize.

    Finally, I set a default sub­sys­tem name (“com.example.perl”). Now users can make it their default Log::Any adapter and start log­ging immediately.

    The fix is in

    With those fix­es, the adapter now works as a native-​grade com­po­nent. No hacks. No guess­ing. Just clean, struc­tured logs flow­ing into the uni­fied log­ging system.

    Each run of brew-patch-upgrade.pl now shows a clear sub­sys­tem, with cat­e­gories for STDOUT, STDERR, and the script itself. This makes it easy to fil­ter and review. And because the script exits with a sum­ma­ry line and prop­er exit code, it slots neat­ly into automa­tion as well.

    In oth­er words, the log­ging side of brew-patch-upgrade.pl is now as patch-​perfect as the upgrade log­ic it supports.

    Looking back, I real­ized that this project was­n’t just about tam­ing Homebrew’s upgrades or fix­ing a stub­born Perl mod­ule. It was about cre­at­ing a stronger con­nec­tion between the tools I rely on and their expect­ed behav­ior. I want them to be pre­dictable, leg­i­ble, and resilient. Both pack­ages work togeth­er, keep­ing my upgrades qui­et and inten­tion­al while sur­fac­ing them in the Console app.

    Both the script and log adapter are on Codeberg, with the adapter also on CPAN. If you’d like to try them out, file issues, or sug­gest improve­ments, the repos are waiting.

  • My mini Mastodon server

    My mini Mastodon server

    Social media is how many peo­ple expe­ri­ence the Internet today. There’s a good chance you found this post through one of them. But the way we con­nect online does­n’t have to be dic­tat­ed by a hand­ful of plat­forms. Beyond the single-​app, single-​site giants, there’s the Fediverse: a con­stel­la­tion of inde­pen­dent social net­works that talk to each other.

    Think of it like email: no sin­gle com­pa­ny in charge, no cen­tral ser­vice to rule them all. It’s pow­ered by a mix of soft­ware for text, pho­tos, pod­casts, events, and more.

    Mastodon is one of the most pop­u­lar of these–a ​“microblog­ging” plat­form like X (née Twitter), but open-​source and fed­er­at­ed. You can join one of thou­sands of servers run by oth­ers… or, if you’re me, you host your own on a tiny Mac mini sit­ting on top of a file cabinet.

    But why?

    I’d been hap­pi­ly toot­ing away on a cou­ple of well-​run Mastodon servers for years. But I kept run­ning into lit­tle things I want­ed to tweak: themes, mod­er­a­tion set­tings, even the domain of my han­dle. None of them were deal-​breakers, but they added up. Eventually, I real­ized that the only way to get exact­ly what I want­ed was to set it up myself.

    The hardware

    When I say tiny, I mean tiny: a low-​spec 2023 M2 Mac mini, 8 GB mem­o­ry, 512 GB solid-​state dri­ve. My wife and I had set it up in our home office to dri­ve a TV dis­play­ing our shared sched­ule. I had also installed a Calibre serv­er for our e‑book library.

    So the mini isn’t even close to break­ing a sweat yet. And I was encour­aged to read that oth­ers were suc­cess­ful­ly run­ning single-​user Mastodon servers on a Raspberry Pi.

    The mini was more than capa­ble. The next step was fig­ur­ing out how to run Mastodon with­out becom­ing a main­te­nance headache.

    The containers

    Mastodon’s offi­cial instal­la­tion instruc­tions involve set­ting up a vari­ety of ser­vices on a Linux VPS (vir­tu­al pri­vate serv­er). But there’s an eas­i­er, lit­er­al­ly more self-​contained way: Docker con­tain­ers, orches­trat­ed through Docker Compose and run­ning via Docker Desktop.

    Mastodon’s source code repos­i­to­ry even includes a starter docker-​compose file describing:

    • a PostgreSQL database
    • a Redis cache
    • the Mastodon web application
    • its ancil­lary stream­ing ser­vice and Sidekiq back­ground event queue

    Everything is con­tainer­ized. Still, there must be a way to keep the host safe from the open Internet.

    Avoiding overexposure

    Directly expos­ing the Mac mini to the full mal­ice of the Internet filled me with dread. And besides, my home con­nec­tion lacks a guar­an­teed fixed address to which I’d attach a domain name. My solu­tion? Cloudflare Tunnel, a ser­vice run by my domain reg­is­trar and name service.

    All I need to do is add anoth­er Docker con­tain­er ser­vice. Cloudflare man­ages the web traf­fic to and from Mastodon. The oth­er ser­vices and the host Mac then stay safe from harm.

    With the tun­nel in place, I can focus on keep­ing the set­up lean and easy to update.

    Aiming for maintainability

    Despite all of these mov­ing parts, I still aim to keep the Mac phi­los­o­phy of sim­plic­i­ty. To change only what I need, I use git to clone Mastodon’s GitHub repos­i­to­ry. Then, I check out the lat­est release tag. Finally, I’ve got this docker-compose.override.yml file with just my mod­i­fi­ca­tions for the var­i­ous con­tain­er services:

    docker-compose.override.yml
    x-mastodon-local: &mastodon-local
      secrets:
        - postgres_password
        - smtp_password
        - redis_password
        - secret_key_base
        - vapid_private
        - ar_enc_primary
        - ar_enc_deterministic
        - ar_enc_salt
      volumes:
        - ./local/scripts/with-secrets.sh:/with-secrets.sh:ro
    
    services:
      # not in upstream
      cloudflared:
        image: cloudflare/cloudflared:latest
        restart: unless-stopped
        secrets:
          - cloudflared_tunnel_token
        environment:
          TUNNEL_TOKEN_FILE: /run/secrets/cloudflared_tunnel_token
        command: tunnel --no-autoupdate run
        depends_on:
          - web
        networks:
          - external_network
    
      # overriding upstream
      db:
        restart: unless-stopped
        secrets:
          - postgres_password
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
        env_file: .env.db.local
        healthcheck:
          test:
            - CMD-SHELL
            - >
              pg_isready --dbname=$$POSTGRES_DB --username=$$POSTGRES_USER
              && psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' >/dev/null
          interval: 30s
          timeout: 5s
          retries: 5
    
      # overriding upstream
      redis:
        restart: unless-stopped
    
      # overriding upstream
      web:
        <<: *mastodon-local
        command: ["/with-secrets.sh", "bundle", "exec", "puma", "-C", "config/puma.rb"]
        restart: unless-stopped
        ports: [] # doesn't actually override, just merges :-(
        expose:
          - 3000
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
          es:
            condition: service_healthy
    
      # overriding upstream
      streaming:
        <<: *mastodon-local
        command: ["/with-secrets.sh", "node", "./streaming/index.js"]
        restart: unless-stopped
        networks:
          - internal_network
        ports: [] # doesn't actually override, just merges :-(
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      # overriding upstream
      sidekiq:
        <<: *mastodon-local
        command: /with-secrets.sh bundle exec sidekiq \
          -q default \
          -q ingress \
          -q mailers \
          -q pull \
          -q push \
          -q scheduler \
          -q search \
          -q indexing_scheduler
        restart: unless-stopped
        healthcheck:
          test: ["CMD", "pgrep", "-f", "sidekiq"]
          interval: 30s
          timeout: 10s
          retries: 3
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
        networks:
          - internal_network
          - external_network
    
      # commented out upstream
      es:
        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4
        restart: unless-stopped
        env_file: .env.es.local
        networks:
          - internal_network
        healthcheck:
          test:
            - CMD-SHELL
            - curl --silent --fail localhost:9200/_cluster/health || exit 1
        volumes:
          - ./elasticsearch:/usr/share/elasticsearch/data
        ulimits:
          memlock:
            soft: -1
            hard: -1
          nofile:
            soft: 65536
            hard: 65536
    
    secrets:
      cloudflared_tunnel_token:
        file: ./local/secrets/cloudflared_tunnel_token.txt
      postgres_password:
        file: ./local/secrets/postgres_password.txt
      smtp_password:
        file: ./local/secrets/smtp_password.txt
      redis_password:
        file: ./local/secrets/redis_password.txt
      secret_key_base:
        file: ./local/secrets/mastodon/secret_key_base.txt
      vapid_private:
        file: ./local/secrets/mastodon/vapid_private.txt
      ar_enc_primary:
        file: ./local/secrets/mastodon/activerecord/encryption_primary.txt
      ar_enc_deterministic:
        file: ./local/secrets/mastodon/activerecord/encryption_deterministic.txt
      ar_enc_salt:
        file: ./local/secrets/mastodon/activerecord/encryption_salt.txt

    Full code list­ings are expand­able on the web­site; email read­ers may need to click through to view them.

    Let’s start from the bot­tom secrets section.

    Secrets management

    Rather than Mastodon’s typ­i­cal approach of expos­ing pass­words, tokens, and oth­er sen­si­tive infor­ma­tion as envi­ron­ment vari­ables, I use a more secure approach. I mount each one as a sep­a­rate text file. Then rel­e­vant ser­vices can read these files direct­ly or pass them as vari­ables on startup.

    To make sure I don’t acci­den­tal­ly com­mit pri­vate infor­ma­tion to git, /local/secrets/ is in my repos­i­to­ry’s .git/info/exclude file.

    Shared configuration with a YAML extension

    Back to the top, and I have a x-mastodon-local YAML exten­sion for shared con­fig­u­ra­tion among sev­er­al of Mastodon’s services:

    x-mastodon-local: &mastodon-local
      secrets:
        - postgres_password
        - smtp_password
        - redis_password
        - secret_key_base
        - vapid_private
        - ar_enc_primary
        - ar_enc_deterministic
        - ar_enc_salt
      volumes:
        - ./local/scripts/with-secrets.sh:/with-secrets.sh:ro

    This lists most of the afore­men­tioned secrets, and mounts a wrap­per script to set them as envi­ron­ment variables.

    The wrapper script

    Speaking of which:

    with-secrets.sh
    #!/bin/sh
    
    set -eu
    
    # Map of secret file name to environment variable name
    for secret in \
      "postgres_password:DB_PASS" \
      "smtp_password:SMTP_PASSWORD" \
      "redis_password:REDIS_PASSWORD" \
      "secret_key_base:SECRET_KEY_BASE" \
      "vapid_private:VAPID_PRIVATE_KEY" \
      "ar_enc_primary:ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" \
      "ar_enc_deterministic:ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" \
      "ar_enc_salt:ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"
    do
      # Split the pair into file name and env var name
      # ${var%%:*} -- strip everything from the first ":" onward
      # (keeps left side)
      name="${secret%%:*}"
      # ${var##*:} -- strip everything up to and including the last ":"
      # (keeps right side)
      var="${secret##*:}"
    
      file="/run/secrets/$name"
    
      # If the secret file exists, read its contents into the env var
      if [ -f "$file" ]
      then
        eval "export $var=\"$(cat "$file")\""
      fi
    done
    
    # Hand off to the original command
    exec "$@"

    Full code list­ings are expand­able on the web­site; email read­ers may need to click through to view them.

    Since Mastodon wants every­thing, secrets includ­ed, as envi­ron­ment vari­ables, this script:

    • loops through a map of their cor­re­spond­ing secrets files
    • exports each of their con­tents to the environment
    • and then runs what­ev­er com­mand was passed along for the ride.

    Customized containerized services

    Remember that docker-compose.override.yml gets merged with the upstream docker-compose.yml file, so I only need to write nec­es­sary changes and addi­tions. Let’s take them one at a time.

    Cloudflare Tunnel

      cloudflared:
        image: cloudflare/cloudflared:latest
        restart: unless-stopped
        secrets:
          - cloudflared_tunnel_token
        environment:
          TUNNEL_TOKEN_FILE: /run/secrets/cloudflared_tunnel_token
        command: tunnel --no-autoupdate run
        depends_on:
          - web
        networks:
          - external_network

    This ser­vice is com­plete­ly new, with noth­ing to over­ride. I spec­i­fy every­thing from the Docker Hub-​based image to the start­up com­mand. I also spec­i­fy the exter­nal net­work used to talk to Cloudflare’s servers.

    Of spe­cial note is the TUNNEL_TOKEN_FILE envi­ron­ment vari­able. This fea­ture was added a mere six months ago. It enables load­ing the Cloudflare-​provided authen­ti­ca­tion token direct­ly from my mount­ed file in /run/secrets. This avoids stuff­ing its con­tents into an envi­ron­ment vari­able itself. I also add a depends_on item for the Mastodon web ser­vice. This ensures that Mastodon is run­ning before I open the tunnel.

    PostgreSQL database

      db:
        restart: unless-stopped
        secrets:
          - postgres_password
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
        env_file: .env.db.local
        healthcheck:
          test:
            - CMD-SHELL
            - >
              pg_isready --dbname=$$POSTGRES_DB --username=$$POSTGRES_USER
              && psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' >/dev/null
          interval: 30s
          timeout: 5s
          retries: 5

    This over­rides sev­er­al set­tings in the upstream docker-compose.yml file, most sig­nif­i­cant­ly in the secrets depart­ment, which fol­lows the same pat­tern as the above cloudflared service.

    And since the upstream git repos­i­to­ry ignores files match­ing .env*.local, I set POSTGRES_DB and POSTGRES_USER envi­ron­ment vari­ables in an .env.db.local file and hard­en the healthcheck.test to both check that PostgreSQL is accept­ing con­nec­tions and that a sim­ple query is suc­cess­ful. That check is run every thir­ty sec­onds and retried up to five times if unsuc­cess­ful. It times out after a mere five sec­onds, more than enough time because every­thing’s run­ning on the same host.

    A brief stop with Redis

      redis:
        restart: unless-stopped

    The only change to upstream’s Redis con­fig­u­ra­tion is restart­ing the ser­vice if it was­n’t man­u­al­ly stopped. (Every oth­er ser­vice in my over­ride file also uses this.) Then I can bring any of them down for main­te­nance or trou­bleshoot­ing, and not wor­ry about docker compose over-​enthusiastically start­ing them up again.

    The Mastodon services

    The Mastodon con­tain­er image itself runs two dif­fer­ent ser­vices, with a third ser­vice next door:

    • web: UI and API
    • sidekiq: back­ground jobs like fed­er­a­tion and media processing
    • streaming: real-​time updates–timelines, notifications–so they arrive instant­ly with­out page reloads
    docker-compose.override.yml (Mastodon ser­vices excerpt)
      web:
        <<: *mastodon-local
        command: ["/with-secrets.sh", "bundle", "exec", "puma", "-C", "config/puma.rb"]
        restart: unless-stopped
        expose:
          - 3000
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      streaming:
        <<: *mastodon-local
        command: ["/with-secrets.sh", "node", "./streaming/index.js"]
        restart: unless-stopped
        networks:
          - internal_network
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
    
      sidekiq:
        <<: *mastodon-local
        command: /with-secrets.sh bundle exec sidekiq
        restart: unless-stopped
        healthcheck:
          test: ["CMD", "pgrep", "-f", "sidekiq"]
          interval: 30s
          timeout: 10s
          retries: 3
        depends_on:
          db:
            condition: service_healthy
          redis:
            condition: service_healthy
        networks:
          - internal_network
          - external_network

    Full code list­ings are expand­able on the web­site; email read­ers may need to click through to view them.

    Each of these ser­vices brings in the mastodon-local YAML exten­sion defined ear­li­er. This approach avoids repeat­ing the same secrets and volumes each time. They also repeat upstream’s command entries, but wrapped in the with-secrets.sh script described above.

    Each ser­vice also has a depends_on stan­za that relies on the PostgreSQL and Redis ser­vice report­ing good health. The rest is just net­work­ing tweaks. lim­it­ing exter­nal expo­sure to the web ser­vice only.

    Again, the point here is main­tain­abil­i­ty and only over­rid­ing what’s not already cov­ered by upstream’s docker-compose.yml file. 

    Bringing it all together

    The con­tain­ers are hum­ming along. Secrets are tucked safe­ly away. Cloudflare qui­et­ly han­dles the out­side world. My lit­tle Mac mini now runs a fully-​fledged Mastodon instance, still with­out break­ing a sweat. It’s not just a proof-of-concept–it’s my dai­ly dri­ver for post­ing, fol­low­ing, and explor­ing the Fediverse.

    Performance has been pleas­ant­ly unevent­ful: CPU and mem­o­ry usage stay low, even dur­ing busy fed­er­at­ed time­lines. The tun­nel has been rock-​solid, and the override-​only approach means I can pull upstream updates with­out dread­ing a merge marathon.

    Lessons learned

    • Secrets-​as-​files keep sen­si­tive data out of the envi­ron­ment and out of ver­sion control–worth the extra setup.
    • Docker over­ride files are a sanity-​saver; upstream changes flow in with­out tram­pling my tweaks.
    • Health checks aren’t just for show–they’ve already caught a mis­be­hav­ing Sidekiq ser­vice before it caused downtime.
    • Cloudflare Tunnel removes the need for a sta­t­ic IP and keeps the host off the pub­lic Internet entirely.

    If you’re thinking of trying this

    Start small. You don’t need a rack of servers–a mod­est machine and a bit of con­tain­er dis­ci­pline can get you sur­pris­ing­ly far. Keep your changes min­i­mal, doc­u­ment them as you go, and let upstream do the heavy lifting.


    Running Mastodon this way has been a reminder that self-​hosting does­n’t have to mean end­less tin­ker­ing. With the right boundaries–both in net­work expo­sure and in con­fig­u­ra­tion scope–it can be calm and pre­dictable. It offers the sat­is­fac­tion of know­ing it’s entire­ly yours. 

    I’ve since enabled ElasticSearch for full-​text search by copy­ing over upstream’s commented-​out es exam­ple ser­vice to my docker-compose.override.yml and light­ly con­fig­ur­ing it. It was a fair­ly sim­ple addi­tion that has­n’t affect­ed per­for­mance on the Mac mini.

    My next steps will involve light­weight mon­i­tor­ing and back­up strate­gies. These steps will guar­an­tee this lit­tle serv­er can keep qui­et­ly doing its job for years to come.

  • Scraping the Dragon with Perl and Mojolicious

    Scraping the Dragon with Perl and Mojolicious

    Every extend­ed Labor Day week­end, 80,000 fans of pop cul­ture descend on Atlanta for Dragon Con. It’s a sprawl­ing choose-​your-​own adven­ture of a con­ven­tion with 38 pro­gram­ming tracks and over 5,000 hours of events. It spans five down­town host hotels, and there is no way to see it all.

    Sadly, this year’s con is almost over. Still, I thought I’d share a lit­tle script I wrote to help me make sense of it all.

    The offi­cial mobile app is fine for search­ing and book­mark­ing events, speak­ers, and exhibitors. Nonetheless, it’s not suit­able for scan­ning the whole land­scape at once. I want­ed a sin­gle, scrol­lable view of every event, before I even packed my cosplay.

    Even in the app’s tablet ver­sion, the Dragon Con Events area is a scroll-fest.

    The web ver­sion of the app gave me exact­ly what I need­ed: pre­dictable per-​day URLs and seman­ti­cal­ly marked-​up HTML. That meant I can skip the API hunt, skip the man­u­al scrolling, and go straight to scraping.

    Inspecting the HTML reveals per-​day event URLs and per-​event <div> blocks.

    From Chaos to Clarity in 40 lines

    We’re about to turn a messy, multi-​day, multi-​hotel sched­ule into one clean, scroll-​once list. This is the forty-​five-​line Perl map that gets us there, aid­ed by the Mojolicious web toolkit.

    Laying the Groundwork: Tools for the Job

    #!/usr/bin/env perl
    
    use v5.40;
    
    use Carp;
    use English;
    use Mojo::UserAgent;
    use Mojo::URL;
    use Mojo::DOM;
    use Mojo::Collection q(c);
    use Time::Piece;
    use HTML::HTML5::Entities;
    use Memoize;
    
    binmode STDOUT, ':encoding(UTF-8)'
      or croak "Couldn't encode STDOUT: $OS_ERROR";
    
    my $ua   = Mojo::UserAgent->new();
    my $site = Mojo::URL->new('https://app.core-apps.com');
    my $path = '/dragoncon25/events/view_by_day';

    What’s hap­pen­ing: Load the mod­ules that will do the heavy lifting–HTTP fetch­es, DOM pars­ing, date han­dling, Unicode cleanup. Lock STDOUT to UTF‑8 so char­ac­ters like curly quotes and em-​dashes don’t break the out­put. Point the script at the base sched­ule URL.

    Remembering the Days Without Re-Parsing

    my $date_from_dom = memoize( sub ($dom) {
      return content_at( $dom, 'div.section_header[class~="alt"]' );
    } );

    What’s hap­pen­ing: Create a mem­o­ized helper that plucks the date from a day’s HTML and caches it. That way, if we need it again, we skip the DOM re-​parse and keep the pipeline fast.

    content_at is a helper func­tion I define later.

    Starting Where the App Starts

    my $today_dom = Mojo::DOM->new( $ua->get("$site$path")->result->text );

    What’s hap­pen­ing: Fetch the ​“today” view–the same default the app shows. This is so we have a known start­ing point for build­ing the full timeline.

    Collecting the Whole Timeline

    my $day_doms = c(
      $today_dom,
      $today_dom->find(qq(div.filter_box-days > a[href^="$path?day="]))
        ->map( \&dom_from_anchor )
        ->to_array->@*,
    )->sort( sub { day_epoch($a) <=> day_epoch($b) } );

    What’s hap­pen­ing: Grab every day link from the fil­ter bar, fetch each day’s HTML, and sort them chrono­log­i­cal­ly. Now we’ve got the entire con’s sched­ule in mem­o­ry, ready to process.

    dom_from_anchor and day_epoch are two more helper func­tions explained fur­ther down.

    Turning HTML into a Human-​Readable Schedule

    $day_doms->each( sub {    # process each day's events
      my $date = $date_from_dom->($_);
    
      $_->find('a.bookmark[data-type="events"] + a.object_link')
        ->each( sub {         # output start time + title
    
          my $time    = content_at( $_, 'div.line[class~="two"]' );
          my $title   = content_at( $_, 'div.line[class~="one"]' );
          my ($start) = split /\s*\p{Dash_Punctuation}/, $time;
    
          say "$date $start: ", decode_entities($title);
        } );
    } );

    What’s hap­pen­ing: For each day, find every event link and pull out the start time and title. Split the time clean­ly on any dash and decode HTML enti­ties so the out­put reads like a real schedule.

    The Little Routines That Make It All Work

    sub dom_from_anchor ($dom) {    # fetch DOM for a day link
      return Mojo::DOM->new(
        $ua->get( Mojo::URL->new( $dom->attr('href') )->to_abs($site) )
          ->result->text );
    }
    
    sub day_epoch ($dom) {    # parse date into epoch
      return Time::Piece->strptime( $date_from_dom->($dom), '%A, %b %e' )
        ->epoch;
    }
    
    # extract and trim text from selector
    sub content_at ( $dom, @args ) { return trim $dom->at(@args)->content }

    What’s hap­pen­ing:

    1. dom_from_anchor: fetch and pars­es a linked days’ HTML.
    2. day_epoch: turn a date string into a sort-​able epoch.
    3. content_at: extract and trim text from a DOM frag­ment, giv­en a CSS selector.

    These helpers keep the main flow read­able and re-usable.

    The Schedule, Unlocked

    Run the script and you get a clean, UTF-​8-​safe list of every event, in chrono­log­i­cal order, across all days. No swip­ing around, no tap­ping, no ​“what did I miss?” anx­i­ety. (Ha, who am I kid­ding? There’s too much going on at Dragon Con to not end up miss­ing something.)

    An exam­ple run of the script in my ter­mi­nal. Each line is ​“Day, Date Time: Event Title”, sort­ed chrono­log­i­cal­ly across the whole con.

    And here’s just a small slice of the 2,500+ lines it produces:

    Sunday, Aug 31 11:30 AM: Unmasking Sherlock: Beyond the Many Faces
    Sunday, Aug 31 11:30 AM: Weaponization of the FCC and Other Agencies to Chill Speech
    Sunday, Aug 31 11:30 AM: Where Physics Gets Weird
    . . .
    Sunday, Aug 31 11:50 AM: Photo Session: Amelia Tyler
    Sunday, Aug 31 11:50 AM: Photo Session: Cissy Jones
    Sunday, Aug 31 11:50 AM: Photo Session: Emma Gregory
    . . .
    Sunday, Aug 31 12:00 PM: Dragon Con Mashups
    Sunday, Aug 31 12:00 PM: James J. Butcher and R.R. Virdi signing at The Missing Volume booth# 1300
    Sunday, Aug 31 12:00 PM: JoeDan Worley and Eric Dontigney signing at the Shadow Alley Press Booth# 2
    . . .
    Sunday, Aug 31 12:00 PM: Photo Session: Robert Duncan McNeill
    Sunday, Aug 31 12:00 PM: Photo Session: Robert Picardo
    Sunday, Aug 31 12:00 PM: Photo Session: Tamara Taylor

    Key Techniques

    Here’s the fun part–the tech­niques that make this tidy, scroll-​once list possible.

    CSS selectors for precision

    I used a.bookmark[data-type="events" + a.object_link] to grab only the event title links, and div.line[class~="two" /​div.line[class~="one"] for time and title, respec­tive­ly. This avoids scrap­ing unre­lat­ed elements.

    Memoization for efficiency

    memoize caches the date string for each day’s DOM so I did­n’t end up re-​parsing the HTML frag­ment mul­ti­ple times.

    Unicode-​safe splitting

    \p{Dash_Punctuation} match­es any dash type (em, en, hyphen-​minus, etc.), so I could split times reli­ably with­out wor­ry­ing about which dash the site uses.

    Functional chaining

    Mojo::Collection​’s map, sort, and each meth­ods let me express the scrape→transform→output pipeline in a lin­ear, read­able way.

    Entity decoding at output

    HTML::HTML5::Entities’ decode_entities is applied right before print­ing, so HTML enti­ties like &amp; or &quot; are human-​readable in the final output.

    A Pattern You Can Take Anywhere

    The same approach that tamed Dragon Con’s chaos works any­where you’ve got:

    • Predictable URLs–so you can iter­ate with­out guesswork
    • Consistent HTML structure–so your selec­tors stay stable
    • A need to see every­thing at once–so you can make deci­sions with­out pag­ing or filtering

    From fan con­ven­tions to con­fer­ence sched­ules, from local sports fix­tures to film fes­ti­val line‑ups–the same pat­tern applies. Sometimes the right tool isn’t a sprawl­ing frame­work or heavy­weight API client. It’s a forty‑odd‑line Perl script that does one thing with ruth­less clarity.

    Because once you’ve tamed a sched­ule like this, the only lines you’ll stand in are the ones that feel like part of the show.

  • Even lighter Perl modulinos with Util::H2O::More

    Even lighter Perl modulinos with Util::H2O::More

    A few weeks ago, I wrote about how to use the mod­uli­no pat­tern in Perl to cre­ate unit-​testable com­mand line tools. Fellow Houston Perl Monger Brett Estrade point­ed me to a dif­fer­ent approach on the Perl Applications & Algorithms Discord. This approach trims boil­er­plate while keep­ing scripts testable.

    Brett’s Utils::H2O::More mod­ule amends the light­weight class builder Utils::H2O. It adds many extra meth­ods, includ­ing com­mand line argu­ment pro­cess­ing via the Perl-​packaged Getopt::Long mod­ule. It also promis­es to build its acces­sors with less cer­e­mo­ny and code than Moo.

    So let’s dive in!

    A simple script

    A script can use Util::H2O::More’s Getopt2h2o func­tion to process com­mand line options. It returns an object with acces­sors for each parameter.

    Here’s a sim­ple exam­ple, mod­eled after my ear­li­er mod­uli­no exer­cise:

    #!/usr/bin/env perl
    
    use v5.38;
    use Util::H2O::More v0.4.2 qw(Getopt2h2o);
    
    # name is a string, water is an array of strings
    my $o = Getopt2h2o \@ARGV, {}, qw(
        name=s
        water=s@
    );
    die "Missing --name\n" unless $o->name;
    
    printf "Good %s, %s!\n", time_of_day(), $o->name;
    
    if ( defined $o->water and $o->water->@* ) {
        say 'What kind of water would you like?';
        say "- $_" for $o->water->@*;
    }
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }

    This is short and read­able. I like how Getopt::Long’s quirky para­me­ter pars­ing syn­tax is repur­posed to cre­ate acces­sors, even for multi-​valued options.

    You can call this script like so:

    ./h2options.pl --name Aquarius --water hot --water cold

    We had to check that --name was set, as Util::H2O::More does­n’t sup­port using an = mod­i­fi­er to spec­i­fy required options. This is some­thing Getopt::Long allows.

    And as Util::H2O’s doc­u­men­ta­tion sug­gests: ​“You should prob­a­bly switch to some­thing like Moo instead [for advanced features].”

    But enough about limitations–what if you want­ed to use this as a Perl mod­ule for testing?

    Testing the waters

    One of the strengths of a mod­uli­no is the abil­i­ty to unit test its log­ic with­out invok­ing it from the shell. A typ­i­cal test script looks like this:

    #!/usr/bin/env perl
    
    use v5.38;
    use Test2::V0;
    use modulinh2o;
    
    plan(6);
    
    can_ok(
        'modulinh2o',
        [ 'time_of_day', 'name', 'water' ],
        'class has methods',
    );
    my $water = modulinh2o->new( name => 'Aquarius' );
    isa_ok( $water, ['modulinh2o'],
            'object is expected class' );
    can_ok(
        $water,
        [ 'time_of_day', 'name', 'water' ],
        'object has methods',
    );
    
    is( $water->name,        'Aquarius', 'name set' );
    is( $water->name('Bob'), 'Bob',      'name change' );
    is( $water->time_of_day,
        in_set( qw(
            morning
            afternoon
            evening
            night
        ) ),
        'time of day function',
    );

    It isn’t dif­fi­cult to adapt a mod­uli­no from our ear­li­er sim­ple script:

    #!/usr/bin/env perl
    
    use v5.38;
    
    package modulinh2o;
    
    use Getopt::Long qw();
    use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
    
    my @opt_spec = qw(
        name=s
        water=s@
    );
    my $o = h2o -classify => __PACKAGE__, {},
            opt2h2o(@opt_spec);
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }
    
    # constructor that parses arguments w/ basic validation
    sub new_with_options ($class) {
        Getopt::Long::GetOptionsFromArray(
          \@ARGV, $o, @opt_spec
        ) or die "bad options\n";
        die "Missing --name\n" unless $o->name;
        return $o;
    }
    
    sub run ($self) {
        printf "Good %s, %s!\n",
               time_of_day(), $self->name;
    
        if ( defined $self->water and $self->water->@* ) {
            say 'What kind of water would you like?';
            say "- $_" for $self->water->@*;
        }
        return;
    }
    
    package main;
    
    main() unless caller;
    
    sub main { modulinh2o->new_with_options->run() }

    And run:

    ./modulinh2o.pm --name Aquarius \
      --water sparkling --water still

    This is much short­er than the Moo-​based mod­uli­no from three weeks ago, but it also does­n’t do as much. There’s no sup­port for using com­ma sep­a­ra­tors to pass mul­ti­ple val­ues to a sin­gle argu­ment. Worse, there’s no auto­mat­ic help text if one pass­es the wrong options.

    Both are fix­able, as we’ll see in a moment. Still, you end up hav­ing to write the POD your­self, print­ed out with var­i­ous invo­ca­tions of Pod::Usage​’s pod2usage() function.

    An ounce of script is worth a gallon of documentation

    Here’s a full exam­ple that adds both --help and --man com­mand line options, as typ­i­cal­ly pro­vid­ed by tra­di­tion­al Getopt::Long-based scripts:

    #!/usr/bin/env perl
    
    use v5.38;
    
    package modulinh2o2;
    
    use Getopt::Long qw();
    use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
    use Pod::Usage;
    
    my @opt_spec = qw(
        name=s
        water=s@
    
        help
        man
    );
    my $o = h2o -classify => __PACKAGE__, {},
            opt2h2o(@opt_spec);
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }
    
    # different parameter mixes for pod2usage()
    my %pod2usage_opt = (
        cmdline => {
            -exitval  => 2,
            -verbose  => 99,
            -sections => 'USAGE/Command line',
            -message  => 'Use --help to list options',
        },
        opts => {
            -exitval  => 0,
            -verbose  => 99,
            -sections => ['USAGE/Command line', 'OPTIONS'],
        },
        man => {
            -exitval => 0,
            -verbose => 2,
        },
    );
    
    sub new_with_options ($class) {
        Getopt::Long::GetOptionsFromArray(
          \@ARGV, $o, @opt_spec
        ) or pod2usage( %pod2usage_opt{cmdline} );
    
        pod2usage( $pod2usage_opt{opts} ) if $o->help;
        pod2usage( $pod2usage_opt{man} )  if $o->man;
        pod2usage( %pod2usage_opt{cmdline},
          -message => 'Missing --name',
        ) unless $o->name;
    
        # default values for the water parameter
        $o->water(
              ( defined $o->water and $o->water->@* )
            ? [ split /,/, join q{,}, $o->water->@* ]
            : [ qw(
                still
                sparkling
                tap
            ) ] );
    
        return $o;
    }
    
    sub run ($self) {
        printf "Good %s, %s!\n",
               time_of_day(), $self->name;
    
        say 'What kind of water would you like?';
        say "- $_" for $self->water->@*;
    
        return;
    }
    
    package main;
    
    main() unless caller;
    
    sub main { modulinh2o2->new_with_options->run() }
    
    # the rest below is documentation
    
    __END__
    
    =head1 NAME
    
    modulinh2o2 - demo of a modulino using Util::H2O::More
    
    =head1 USAGE
    
    =head2 Command line
    
        modulinh2o2.pm [options]
    
    =head2 Perl
    
        use modulinh2o2;
        my $water = modulinh2o2->new(
            name  => 'Aquarius',
            water => [ qw(
                sparkling
                still
                tap
            ) ],
        );
        $water->run;
    
    =head1 OPTIONS
    
    =over
    
    =item B<--name>
    
    Your name here! (required)
    
    =item B<--water>
    
    Type of water to serve. May be specified multiple times, either by repeating the option or separated by commas.
    
    Takes an arrayref when used as a method or construction parameter.
    
    Default values:
    
    =over
    
    =item still
    
    =item sparkling
    
    =item tap
    
    =back
    
    =item B<--help>
    
    Displays a brief help message.
    
    =item B<--man>
    
    Display full documentation as a manual page.
    
    =back
    
    =head1 DESCRIPTION
    
    A sample L<Util::H2O::More> modulino that prints your name, what part of the day it is, and a menu of water choices.
    
    =head1 METHODS
    
    =head2 new
    
    Constructor that takes the above L</OPTIONS> but without the preceding C<-->.
    
    =head2 new_with_options
    
    Alternate constructor that receives its parameters from C<@ARGV>.
    
    =head2 run
    
    Prints out a greeting along with a selection of refreshing drinks.
    
    =head2 ACCESSOR OPTIONS
    
    All of the L</OPTIONS> above are also available as method accessors but without the preceding C<-->.
    
    =head1 FUNCTIONS
    
    =head2 time_of_day
    
    Returns the period of the day based on local time. Possible values are:
    
    =over
    
    =item morning
    
    =item afternoon
    
    =item evening
    
    =item night
    
    =back
    
    =head1 AUTHOR
    
    Mark Gardner <[email protected]>
    
    =head1 LICENSE AND COPYRIGHT
    
    This software is copyright (c) 2025 by Mark Gardner.
    
    This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.

    Now we can run either:

    ./modulinh2o2.pm --help

    to get a quick help mes­sage, or:

    ./modulinh2o2.pm --man

    to show the full man­u­al page.

    Yes, over half of the line count is doc­u­men­ta­tion. Even grant­i­ng this the code amount is now com­pa­ra­ble to the Moo-​based ver­sion. And you don’t get the advan­tage of MooX::Options’ declar­a­tive syntax.

    To sum­ma­rize, here’s a table chart­ing the evo­lu­tion of our modulinh2o:

    StageProsConsComplexity
    Simple script:
    Getopt2h2o only
    * Minimal code foot­print
    * Instant acces­sors from CLI argu­ments
    * Multi-​valued options via @ syntax
    * No required-​argument enforce­ment
    * No auto-​help
    * Manual validation
    Low:
    about 20 lines, one file
    Basic mod­uli­no:
    h2o + opt2h2o
    * Testable as a mod­ule
    * Reusable new_with_options con­struc­tor
    * Keeps brevi­ty vs. Moo
    * Still no comma-​separated multi-​values
    * No auto-​help on bad arguments
    Medium:
    adds pack­age struc­ture, test harness
    Full mod­uli­no w/​help & man:
    Pod::Usage inte­grat­ed
    * Support for --help and --man
    * Comma-​separated multi-​values han­dled
    * Defaults for miss­ing --water
    * Clearer on-​boarding for new users
    * More boil­er­plate
    * Over half the file is POD
    * Still less declar­a­tive than MooX::Options
    High:
    more mov­ing parts, but user-friendly
    Util::H2O::More mod­uli­no evo­lu­tion and fea­ture trade-offs

    Seen togeth­er, these stages trace a course from a bare-​bones script to a well-​provisioned mod­uli­no. Each step adds fea­tures at the cost of a lit­tle more complexity.

    In the end, Util::H2O::More deliv­ers a lean, testable mod­uli­no with far less boil­er­plate than Moo–but also few­er built‑in niceties. If you val­ue speed from idea to work­ing script and can live with­out declar­a­tive option han­dling, it’s a com­pelling choice. For more struc­tured needs, MooX::Options still has the edge. Either way, the path from script to production-​ready mod­uli­no is short­er than you think.

    The best tool is the one that flows from idea to ​“it works” in a sin­gle pour.