Tag: Mac

  • Migrating from Docker Desktop to Colima: When Hardened Images Break

    Migrating from Docker Desktop to Colima: When Hardened Images Break

    By 10:25 AM, I’d entered what Mystery Science Theater 3000 fans call ​“Deep Hurting.” The migra­tion plan was sol­id. The back­up dis­ci­pline was com­pre­hen­sive. The exe­cu­tion? Chaos.

    I run a con­tainer­ized pro­duc­tion Mastodon instance on an 8 GB Mac mini. (Yes, I know what the cloud peo­ple say, and FYI it’s Cloudflare Tunneled for pro­tec­tion.) My Docker Desktop installation’s half-​gig RAM foot­print was eat­ing pre­cious resources. Colima promised the same Docker expe­ri­ence with­out the GUI over­head. I bud­get­ed a 1.5 hour migra­tion plan for what should’ve been a straight­for­ward run­time swap.

    Two and a half hours and sev­en crit­i­cal issues lat­er, I’d dis­cov­ered that Docker Hardened Images and Colima don’t play nice­ly togeth­er. And that dis­cov­ery mat­ters to any­one run­ning hard­ened con­tain­ers in vir­tu­al­ized environments.


    The Plan (That Didn’t Survive Contact with Reality)

    The strat­e­gy was text­book: main­te­nance win­dow approach, com­pre­hen­sive back­ups (data­base dumps, vol­ume archives, con­fig­u­ra­tion snap­shots), explic­it roll­back pro­ce­dures. I’d stop Docker Desktop, switch the Docker con­text to Colima, update one path in the Makefile I use to auto­mate tasks, and restart ser­vices. Everything uses bind mounts, so data stays on the host file sys­tem. What could go wrong?

    Everything. Everything could go wrong.

    Obsolete Makefile references

    First back­up try:

    service "db" is not running

    Wait–what’s db? I migrat­ed from ver­sion 14 to ver­sion 17 of the PostgreSQL rela­tion­al data­base sys­tem weeks ago. Switched and even switched from the default PostgreSQL image to a Docker Hardened Image (DHI), even. My com­pose files ref­er­ence db-pg17. But the Makefile’s back­up tar­gets? Still call­ing the old db ser­vice. The PostgreSQL migra­tion doc­u­men­ta­tion lived in the README file that I keep. The Makefile lived in… a dif­fer­ent men­tal con­text apparently.

    Lesson: When you migrate infra­struc­ture com­po­nents, grep for ref­er­ences every­where. Compose files, Makefiles, scripts, doc­u­men­ta­tion. ​“It’s work­ing” means ​“it’s work­ing right now,” not ​“the migra­tion completed.”

    The empty postgres17/ directory

    After resolv­ing the data­base restore issues (we’ll get there), con­tain­ers start­ed suc­cess­ful­ly. Then I ran a restart test. PostgreSQL came up empty–no data, no tables, fresh initialization.

    % ls -la postgres17/
    total 0
    drwxr-xr-x@ 2 markandsharon staff 64 Jan 7 16:31 .

    64 bytes. An emp­ty direc­to­ry. That December PostgreSQL 14 → 17 ​“migra­tion”? Created the direc­to­ry, nev­er pop­u­lat­ed it. PostgreSQL 14 data stayed in postgres14/. Docker Desktop must’ve been using cached or inter­nal storage.

    Lesson: Don’t trust that migra­tions suc­ceed­ed because ser­vices are healthy. Check the actu­al data files. Persistence isn’t per­sis­tence if noth­ing’s persisting.

    Wrong database target

    After fix­ing the Makefile, ser­vices start­ed… and instant­ly crash-looped:

    PG::UndefinedTable: ERROR:  relation "users" does not exist

    PostgreSQL was healthy. The appli­ca­tion dis­agreed. Turns out I’d restored the dump to the wrong database:

    # What I did (wrong):
    psql -U mastodon postgres < dump.sql
    # What I should have done:
    psql -U mastodon mastodon_production < dump.sql

    The mastodon_production data­base existed–it was just emp­ty. All my data went into the postgres data­base that noth­ing was read­ing. The psql command-​line client defaults to the data­base match­ing your user­name or postgres if unspec­i­fied. Explicit is bet­ter than implic­it, espe­cial­ly when you’re in a hurry.

    Version-​specific PGDATA paths

    Once data land­ed in the right data­base, I hit a new prob­lem: data did­n’t per­sist across restarts. The bind mount direc­to­ry stayed emp­ty even though PostgreSQL was run­ning and accept­ing writes.

    It turns out that my PostgreSQL DHI uses version-​specific paths:

    # My bind mount:
    - ./postgres17:/var/lib/postgresql/data
    # Actual DHI PostgreSQL data directory:
    # PGDATA=/var/lib/postgresql/17/data

    The mount shad­owed the wrong direc­to­ry. PostgreSQL wrote data to /var/lib/postgresql/17/data, which was­n’t mount­ed. Data lived in ephemer­al con­tain­er stor­age. Restart? Data gone.

    $ docker compose exec db-pg17 psql -U mastodon postgres -c "SHOW data_directory;"
           data_directory
    -----------------------------
     /var/lib/postgresql/17/data

    Lesson: Verify assump­tions. Every sin­gle one. Check SHOW data_directory; imme­di­ate­ly after con­tain­er start. Test a restart before cel­e­brat­ing success.

    I cor­rect­ed the mount path to match DHI’s expect­ed loca­tion. That’s when I found the real problem.

    The DHI + Colima Incompatibility Discovery: VirtioFS bind mount ownership failures

    After cor­rect­ing the mount path, PostgreSQL entered an imme­di­ate crash-loop:

    FATAL: data directory "/var/lib/postgresql/17/data" has wrong ownership
    HINT: The server must be started by the user that owns the data directory.

    Inside the con­tain­er, the mount­ed direc­to­ry appeared owned by the root user (user ID 0). But PostgreSQL runs as the postgres user. Permission denied.

    % docker compose run --rm --entrypoint sh db-pg17 -c "ls -ld /var/lib/postgresql/17/data"
    drwxr-xr-x 2 0 0 4096 Jan 10 16:22 /var/lib/postgresql/17/data
    # Owner: UID 0 (root), but PostgreSQL requires postgres user ownership

    Colima uses the VirtioFS sys­tem for file shar­ing. VirtioFS han­dles UID map­ping dif­fer­ent­ly than Docker Desktop’s vir­tu­al machine (VM) imple­men­ta­tion. Bind mounts that work per­fect­ly on Docker Desktop fail on Colima because the own­er­ship map­ping does­n’t translate.

    Fine. This is a known issue with Colima and some images. I’ll switch to a named volume–Docker man­ages those inter­nal­ly, so host filesys­tem per­mis­sions should­n’t matter.

    Named vol­umes still failed:

    FATAL: data directory "/var/lib/postgresql/17/data" has wrong ownership

    Wait. Named vol­umes are sup­posed to be iso­lat­ed from host file sys­tem issues. They’re man­aged entire­ly by Docker. Fresh named vol­ume, Docker cre­ates it, Docker pop­u­lates it–and it still shows wrong own­er­ship inside the DHI container.

    # Fresh named volume:
    % docker compose run --rm --entrypoint sh db-pg17 -c "ls -ld /var/lib/postgresql/17/data"
    drwxr-xr-x 2 0 0 4096 Jan 10 16:22 /var/lib/postgresql/17/data

    DHI PostgreSQL’s entry­point has envi­ron­men­tal assump­tions that Colima’s VM does­n’t sat­is­fy. The image’s secu­ri­ty hard­en­ing includes stricter own­er­ship val­i­da­tion. That val­i­da­tion does­n’t account for Colima’s vol­ume handling.

    The pragmatic trade-off

    So I had to make a decision:

    1. Debug DHI + Colima com­pat­i­bil­i­ty (unknown time invest­ment, might be unsolv­able), or
    2. Switch to the stan­dard postgres:17-alpine image (known work­ing, imme­di­ate resolution)

    Production sys­tem. Already 1.5 hours into debug­ging. Swap the image:

    # Before (DHI):
    image: dhi.io/postgres:17-alpine3.22
    volumes:
      - postgres17-data:/var/lib/postgresql/17/data
    # After (Standard):
    image: postgres:17-alpine
    volumes:
      - postgres17-data:/var/lib/postgresql/data

    PostgreSQL ini­tial­ized suc­cess­ful­ly. Data per­sist­ed across restarts. Services came up healthy.

    The trade-​off:

    • ✓ Gained: Colima com­pat­i­bil­i­ty, reli­able data per­sis­tence, onward progress
    • ❌ Lost (tem­porar­i­ly): DHI secu­ri­ty hardening–documented for future investigation

    Docker Hardened Images offer secu­ri­ty fea­tures through stricter defaults and entry­point val­i­da­tion. Those same strict require­ments reduce the com­pat­i­bil­i­ty sur­face. When you intro­duce a dif­fer­ent vir­tu­al­iza­tion envi­ron­ment (Colima’s VirtioFS instead of Docker Desktop’s VM), the hard­en­ing becomes brittleness.

    This isn’t DHI’s fault–it’s the expect­ed con­se­quence of defense-​in-​depth. But if you’re migrat­ing from Docker Desktop to Colima, test your image com­pat­i­bil­i­ty in iso­la­tion first. This is cru­cial if you are using Docker Hardened Images. Carry out these tests before migra­tion day.


    The Outcome

    Migration com­plet­ed at 11:30 AM. Zero data loss. All ser­vices healthy. Automation restored. RAM reclaimed (Docker Desktop’s over­head vs. Colima’s neg­li­gi­ble footprint).

    The real out­come was discovering–systematically, through elimination–that DHI PostgreSQL and Colima are incom­pat­i­ble with­out fur­ther inves­ti­ga­tion. I’ve doc­u­ment­ed this as a known issue. Future work: test DHI with dif­fer­ent vol­ume strate­gies, check whether new­er DHI ver­sions resolve the issue, eval­u­ate whether the secu­ri­ty delta mat­ters for a single-​user instance.

    For now, I’m run­ning stan­dard postgres:17-alpine. The migra­tion is suc­cess­ful. The secu­ri­ty regres­sion is doc­u­ment­ed and sched­uled for future inves­ti­ga­tion. Forward progress beats perfectionism.

    Key Takeaways

    Backups are your safe­ty net–use them. I restored the data­base once dur­ing this migra­tion. That restore took 30 sec­onds because I’d ver­i­fied the back­up exist­ed and was recent.

    Systematic debug­ging beats pan­ic every time. Bind mounts failed → tried named vol­umes → still failed → iso­lat­ed to image-​specific behav­ior. That pro­gres­sion ruled out host file sys­tem issues and point­ed direct­ly at image compatibility.

    Pragmatic trade-​offs beat per­fec­tion­ism. I could’ve spent hours debug­ging DHI com­pat­i­bil­i­ty. Instead, I doc­u­ment­ed the incom­pat­i­bil­i­ty, switched to stan­dard images, and moved on. The secu­ri­ty regres­sion is tracked. The pro­duc­tion sys­tem is running.

    Document fail­ures hon­est­ly; they’re learn­ing oppor­tu­ni­ties. This post exists because the migra­tion did­n’t go smooth­ly. The DHI + Colima incom­pat­i­bil­i­ty is now doc­u­ment­ed for any­one else hit­ting the same issue. That’s more valu­able than a ​“here’s how I moved from X to Y” suc­cess story.

    Migration dura­tion2.5 hours actu­al vs. 1.5 hours planned
    Issues encoun­tered7 crit­i­cal
    Data loss0 bytes
    ServicesAll healthy
    Memory reclaimed~500 MB
    Novel dis­cov­er­ies1 (DHI + Colima incompatibility)
    Trade-​offs documented1 (secu­ri­ty hard­en­ing vs. compatibility

    Running pro­duc­tion infra­struc­ture on an 8 GB Mac mini teach­es you to val­ue both resources and reli­a­bil­i­ty. Colima deliv­ers on the resources. This migra­tion deliv­ered on the reli­a­bil­i­ty… eventually.

  • Claude Code CLI over SSH on macOS: Fixing Keychain Access

    Claude Code CLI over SSH on macOS: Fixing Keychain Access

    Claude Code is a pow­er­ful command-​line tool for agen­tic soft­ware devel­op­ment. However, if you try to use it over an SSH secure shell ses­sion on macOS, you may see a con­fus­ing mix of ​“Login suc­cess­ful” and ​“Missing API key” mes­sages. The root cause: Claude Code’s OAuth token lives in the macOS Keychain, which SSH ses­sions can’t access by default.

    Here’s a quick fix that took about 10 min­utes to build — with Claude Code’s help. (Meta, but effective.)

    The Fix

    Add this to your ~/.zshrc:

    # Wrapper function to unlock keychain before running claude
    claude() {
      if [ -n "$SSH_CONNECTION" ] && [ -z "$KEYCHAIN_UNLOCKED" ]
      then
        security unlock-keychain ~/Library/Keychains/login.keychain-db
        export KEYCHAIN_UNLOCKED=true
      fi
      command claude "$@"
    }

    Reload your shell (source ~/.zshrc), then run claude over SSH. It will prompt for your key­chain pass­word once per ses­sion, then work normally.

    How It Works

    1. Detects SSH ses­sions via $SSH_CONNECTION
    2. Unlock the key­chain once per ses­sion, using $KEYCHAIN_UNLOCKED to guard against mul­ti­ple attempts
    3. Delegate to the real claude com­mand with all argu­ments passed

    The key­chain stays unlocked for the dura­tion of your SSH ses­sion, so you only enter the pass­word once.

    Security note: This does­n’t bypass macOS Keychain secu­ri­ty. It just prompts you once per SSH ses­sion, the same as if you’d unlocked it locally.

    With this wrap­per in place, I can get Claude Code to behave over SSH exact­ly as it does local­ly. There are no sur­pris­es and no API keys, and my Claude Pro login works as expected.

    The broader lesson

    Command line tools that rely on the macOS Keychain often break over SSH. Wrapping those tools with the security unlock-keychain com­mand gen­er­al­ly fix­es those issues.

  • 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.

  • Kaiju Boss Battle: A Dist::Zilla Journey from Chaos to Co-Op

    Kaiju Boss Battle: A Dist::Zilla Journey from Chaos to Co-Op

    Last week I wrote about devel­op­ing a Perl mod­ule enabling me to out­put log entries to macOS’ uni­fied log­ging sys­tem. This week­end’s adven­ture involved port­ing that mod­ule’s man­u­al process­es. These process­es includ­ed depen­den­cy man­age­ment, doc­u­men­ta­tion sync­ing, ver­sion bump­ing, and release. The goal was to make every­thing more auto­mat­ed and repeatable.

    And maybe even… monstrous.

    I was already famil­iar with the Dist::Zilla (DZil to its friends) suite of tools and plu­g­ins. I also knew that some Perl devel­op­ers view it as a huge bar­ri­er to entry. This per­cep­tion affects their will­ing­ness to con­tribute to oth­ers’ projects.

    So even though Log-​Any-​Adapter-​MacOS-​OSLog was a small mod­ule of inter­est to a lim­it­ed cod­ing audi­ence (macOS Perl users), I thought it wise to have both a main branch and sep­a­rate build/main branch for those pro­gram­mers who want­ed to work as though things had bare­ly changed:

    • Entire source code with full POD-​formatted documentation;
    • A Makefile.PL script to gen­er­ate a portable build­ing, test­ing, and installing Makefile;
    • Plus, every Perl dis­tri­b­u­tion should sup­ply the typ­i­cal README, MANIFEST, LICENSE, and CONTRIBUTING doc­u­men­ta­tion. This is essen­tial if it’s meant for pub­lic consumption.

    A small dis­tri­b­u­tion would also give me a mod­el I can scale up for larg­er projects. At the very least, it was anoth­er learn­ing oppor­tu­ni­ty for me.

    Boy, was it.

    Why Dist::Zilla?

    Because I know it can auto­mate away the boil­er­plate code and rep­e­ti­tion of infor­ma­tion that’s unfor­tu­nate­ly nec­es­sary in a mod­ern Perl mod­ule distribution:

    • the ver­sion num­bers in every mod­ule and script
    • the README that often includes the same intro­duc­to­ry text as the main mod­ule’s documentation
    • the nam­ing, order, and con­tent of POD sec­tions (via DZil’s sis­ter suite, Pod::Weaver), some of which repeat dis­tri­b­u­tion meta­da­ta like author, ver­sion, sup­port, copy­right, license, and so on

    If you need fur­ther detail, Dan Book’s Dist::Zilla::Starter is, as its name sug­gests. an excel­lent and remark­ably thor­ough guide to the how and why of Dist::Zilla. It even cov­ers the basic struc­ture of CPAN dis­tri­b­u­tions and the his­to­ry of Perl mod­ule author­ing tools.

    Why not something else?

    Because just as in the sto­ry of Goldilocks and the three bears, every­thing else I looked at seemed want­i­ng in some way:

    • Module::Build/​Module::Build::Tiny: Although min­i­mal, pure-​Perl, and easy to install, Module::Build prop­er still requires ship­ping extra boil­er­plate and dupli­cate meta­da­ta. ::Tiny shaves that down fur­ther but drops whole swaths of func­tion­al­i­ty. As an exam­ple, you can’t spec­i­fy at set­up time that users need a spe­cif­ic oper­at­ing sys­tem. It’s a deal-​breaker for a mod­ule that requires macOS 10.12 Sierra or newer.
    • Minilla: Opinionated con­ven­tion over con­fig­u­ra­tion, but I don’t share its opin­ions. Overriding direc­to­ry lay­out, test struc­ture, and README/​license han­dling would mean fight­ing Minilla’s defaults, DZil-​config style, with­out DZil’s plu­g­in ecosystem.
    • ShipIt: Simple, one-​file con­fig­u­ra­tion. But too sim­ple: it’s most­ly release automa­tion and not author­ing automa­tion. Everything I want­ed to tool away is still manual.
    • No or min­i­mal tool­chain: That was how I start­ed this mod­ule, with ExtUtils::MakeMaker. Totally man­u­al, with every con­trib­u­tor, includ­ing yours tru­ly, need­ing to remem­ber all the mov­ing parts by hand.

    Off to see the lizard!

    My DZil dist.ini con­fig­u­ra­tion file start­ed off sim­ply enough:

    name    = Log-Any-Adapter-MacOS-OSLog
    author  = Mark Gardner <[email protected]>
    license = Perl_5
    copyright_holder = Mark Gardner
    version = 0.0.5 ; bump as appropriate
    
    [MetaResources]
    repository.type = git
    bugtracker.web  = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog/issues
    
    [Repository]
    web = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog

    That [MetaResources] is the first evi­dence of using plu­g­ins. It pro­vides a nice tidy way to spec­i­fy the meta­da­ta. Automated tools can use this infor­ma­tion to index, exam­ine, pack­age, or install Perl distributions.

    So far so good. But then the mon­ster attacked.

    [@Filter]
    -bundle = @Basic
    -remove = GatherDir
    -remove = MakeMaker
    -remove = Readme
    ; License plugin still active here
    
    [GatherDir]
    include_dotfiles = 1
    exclude_filename = .gitignore
    exclude_filename = .build
    
    [ReadmeAnyFromPod]
    type = markdown
    filename = README.md
    location = build
    
    [CopyFilesFromBuild]
    copy = README.md
    
    ; various MakeMaker::*, Git::*, Pod::Weaver, etc. plugins

    I was get­ting errors from the dzil --build com­mand as it attempt­ed to add the same file mul­ti­ple times. These were gen­er­at­ed files com­mit­ted to the main ver­sion con­trol branch and a build/main branch for non-​DZil contributors.

    This brought me to a dead stop. I went down a rabbit-​hole exam­in­ing built files from rsync(1) with [Run::AfterBuild]., com­par­ing them to the branch I had set up.

    Plus my .perlcriticrc file was dis­ap­pear­ing from the build arti­facts despite that instruc­tion to [GatherDir] to include dot­files.

    “Let them fight.”

    LICENSE to kill (files)

    Eventually I traced the LICENSE file dupli­ca­tion to duel­ing DZil plu­g­ins. The afore­men­tioned [GatherDir] was duti­ful­ly copy­ing the file from my work­ing root even as the [License] plu­g­in was gen­er­at­ing it. I did­n’t want to lose the lat­ter source of what­ev­er license I hap­pened to be using to dis­trib­ute this code.

    And .perlcriticrc? It turns out the [PruneCruft] plu­g­in does­n’t lis­ten to its friend [GatherDir] and was hoover­ing it away.

    In the end, I had to expand my manually-​configured plu­g­in ros­ter a lit­tle to keep them from fight­ing over what files came from where:

    [@Filter]
    -bundle = @Basic
    -remove = GatherDir
    -remove = PruneCruft
    -remove = MakeMaker
    -remove = Readme
    
    [PruneCruft]
    except = .perlcriticrc
    
    [GatherDir]
    include_dotfiles = 1
    exclude_filename = .gitignore
    exclude_filename = .build
    exclude_filename = LICENSE
    
    [ReadmeAnyFromPod]
    type            = markdown
    filename        = README.md
    
    [CopyFilesFromBuild]
    copy = README.md
    copy = LICENSE

    With these tweaks to Log-​Any-​Adapter-​MacOS-​OSLog’s dist.ini:

    • [License] gen­er­ates its file,
    • [CopyFilesFromBuild] copies it along with the README into the root for my commit,
    • And [GatherDir] does­n’t stomp on it like so many Tokyo city blocks.

    The build/main branch for non-​DZil con­trib­u­tors would con­tain exact­ly what they and I expect­ed. It would be a match of the DZil work­ing copy plus arti­facts, git cloneable with no surprises.

    Lessons learned, and what’s next?

    I want­ed to serve two styles of devel­op­ment and had signed myself up for a com­pli­cat­ed author­ing process. I now have more knowl­edge of which plu­g­in runs in each phase of the build. This allows me to decide between gen­er­at­ed and version-​controlled files.

    CPAN has a rich vari­ety of per­son­al Dist::Zilla::PluginBundle::s fac­tor­ing indi­vid­ual authors’ pref­er­ences into a sin­gle place. Those authors don’t have to copy-​and-​paste DZil con­fig­u­ra­tions around. It’s past time for me to mint one of my own bun­dles for more con­sis­tent and well-​understood Perl dis­tri­b­u­tions. Then I can start spin­ning up new projects with­out revis­it­ing the same pain.


    This week­end has brought on a men­tal shift. I moved from fight­ing DZil’s defaults to mak­ing it work my way. I also found sat­is­fac­tion in a pre­dictable, minimal-​effort Perl release pipeline.

    The mon­ster was just a guy in a rub­ber suit all along.