TL;DR

  • Everything in my resale pipeline runs on CronJobs, which is correct until the moment you want fresh data now and the next run is 40 minutes out.
  • Fix: a refresh button on each dashboard that creates a Kubernetes Job from the existing CronJob’s template, via the API, using a dedicated ServiceAccount with a tightly scoped Role.
  • The web app gets permission to create Jobs in one namespace and nothing else. No cluster-admin, no shelling out to kubectl, no shared token.
  • Six dashboards: opportunities, active bids, sold, listings, pickups, contacts. Every table sorts on every column, which is not a nice-to-have — unsorted tables hide your worst-performing categories.
  • The metric that changed my buying wasn’t margin. It was days from sourced to sold, by category — and it says two categories I liked were quietly eating all my working capital.

Cron is right until you’re standing in front of the shelf

The pipeline is scheduled work. Crawlers every six hours, evaluation hourly, order sync every six hours, pack slips twice an hour. That’s the right architecture: it’s cheap, it’s resilient, it recovers on the next tick, and nothing depends on me being awake.

It has one bad property. Scheduled work is stale by design, and staleness has a cost that isn’t uniform. Nobody cares whether the sold-items report is forty minutes old. But:

  • An auction closes in eleven minutes and I want to know the current bid now.
  • I just marked six units shipped and want the dashboard to agree before I hand someone a status.
  • A crawler run half-failed and I want to re-run that one search zone without waiting for the cycle.

For a while I handled those the way everyone does: SSH to a machine with cluster credentials and run kubectl create job --from=cronjob/whatever. Which works, and is also the exact pattern I keep telling myself not to build a workflow on. It requires a laptop, cluster credentials, and remembering the CronJob names.

The refresh button

So each dashboard got a button. Clicking it creates a one-off Job from the corresponding CronJob’s pod template and returns immediately; the page polls for completion.

The mechanism is the boring part, which is why it’s worth writing down — the Kubernetes API does this natively and you don’t need an operator or a queue:

from kubernetes import client, config

config.load_incluster_config()
batch = client.BatchV1Api()

def refresh(cronjob_name: str, namespace: str = "flipping") -> str:
    cj = batch.read_namespaced_cron_job(cronjob_name, namespace)
    name = f"{cronjob_name}-manual-{int(time.time())}"

    job = client.V1Job(
        metadata=client.V1ObjectMeta(
            name=name,
            labels={"app.kubernetes.io/managed-by": "tracker-webapp",
                    "trigger": "manual"},
            owner_references=[client.V1OwnerReference(
                api_version="batch/v1", kind="CronJob",
                name=cj.metadata.name, uid=cj.metadata.uid,
            )],
        ),
        spec=client.V1JobSpec(
            template=cj.spec.job_template.spec.template,
            backoff_limit=0,
            ttl_seconds_after_finished=3600,
        ),
    )
    batch.create_namespaced_job(namespace, job)
    return name

Three details that matter more than the rest:

owner_references pointing at the CronJob. This makes the manual Job a child of the CronJob, so deleting the CronJob cleans up its manual runs, and the relationship is visible in kubectl describe. Without it you accumulate orphaned Jobs with no obvious provenance.

ttl_seconds_after_finished. Manual Jobs get triggered on impulse, several times a day. Without a TTL, completed Jobs and their pods pile up in the namespace until listing pods becomes genuinely slow. An hour is plenty.

backoff_limit=0. A manual refresh that fails should fail visibly and stay failed. Retrying a user-initiated action behind their back means they see stale data and a green tick.

Permissions: one namespace, one verb set

The web app runs with its own ServiceAccount whose Role is deliberately tiny:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: tracker-webapp-jobs
  namespace: flipping
rules:
  - apiGroups: ["batch"]
    resources: ["cronjobs"]
    verbs: ["get", "list"]
  - apiGroups: ["batch"]
    resources: ["jobs"]
    verbs: ["get", "list", "create", "watch"]

A Role, not a ClusterRole. One namespace. It can read CronJob templates and create Jobs; it cannot delete anything, cannot touch Deployments, cannot read Secrets, and has no visibility outside its own namespace.

This is the part I’d push hardest on if you build something similar. “A web app that can create Kubernetes workloads” is a genuinely spicy capability, and the thing that makes it acceptable is that the blast radius is one namespace and the verb list is five words long. It’s tempting to give it broader rights so the next feature is easier. Don’t — the next feature can get its own rule, reviewed on its own merits.

There’s an application-side guard too: the button only accepts CronJob names from a hardcoded allowlist mapped to specific dashboards. It doesn’t take a name from the request. A “refresh” endpoint that accepts an arbitrary CronJob name is an endpoint that runs arbitrary in-namespace workloads, and the allowlist costs one dictionary.

Six dashboards

  • Opportunities — live evaluated auction lots, ranked by headroom. The buying screen.
  • Active bids — what I’m currently in on, current price versus my ceiling, closing time.
  • Sold — completed sales with realised margin, and reconciliation status.
  • Listings — what’s public right now, how long it’s been up, view counts where available.
  • Pickups — won lots awaiting collection, with location and deadline. Surplus auctions have hard pickup windows and missing one forfeits the lot, which concentrates the mind.
  • Contacts — repeat buyers, inbound enquiries, and draft replies waiting on me.

Each has its own refresh, wired to the job that feeds it.

And every table sorts on every column. I want to be unreasonable about this for a second, because it’s the highest-value UI decision in the whole system and it’s usually treated as polish.

An unsorted table shows you recency, which is the least interesting axis. Sortable columns let you ask questions: which listing has been up longest without selling? (sort listings by days-live, descending — that’s your dead stock). Which category has the worst realised margin? (sort sold by margin, ascending — that’s what to stop buying). What’s closing soonest? (sort bids by close time).

None of those need a report, a chart, or a query. They need a column header that responds to a click. I have written far too many bespoke “reports” that a sortable column would have answered, and I now treat sortable-by-default as a requirement rather than a feature.

The metric that changed my buying

Here’s what surprised me once the data was sortable.

I’d been optimising margin. Percentage return per unit. It’s the obvious number, it’s what every reseller talks about, and it’s what my bidding ceiling is built around.

Then I sorted the sold table by days from sourced to sold and grouped by category. Two categories I genuinely enjoyed buying — because the margins looked great — turned out to average four to six months on the shelf. Meanwhile a category I found boring was averaging under three weeks at a lower margin.

Run the arithmetic on capital turns rather than per-unit margin and the boring category wins outright. A 30% margin realised four times a year returns far more on the same dollar than a 55% margin realised once. This is not a novel insight — it’s inventory management, and every retailer knows it — but I had managed not to notice it for over a year, because margin is the number that’s easy to see and velocity is the number that requires timestamps you have to have thought to record.

That’s the real argument for the status machine I described in the cost-basis post: sourced_date and sold_date on every unit aren’t bookkeeping, they’re the input to the only metric that changed my behaviour.

The change in practice: my bidding ceiling now takes expected days-to-sale into account, not just expected margin. Slow categories need a materially better margin to clear the bar. Two categories I used to chase, I now skip.

What I’d do differently

Add the timestamps first. Every state transition, timestamped, from day one. They’re free to record and impossible to backfill. The velocity insight was available to me for a year and I couldn’t see it because the data didn’t exist.

Build the boring dashboard before the clever one. The pickups dashboard — a list with dates and addresses — has prevented one forfeited lot, which paid for the whole afternoon it took. The evaluation-quality dashboard I was excited about gets opened once a fortnight.

Don’t put a chart where a sorted column would do. I built a couple of charts early on. I look at the tables.

Gotchas

Manual Jobs need a TTL or your namespace fills up. Completed pods aren’t free; kubectl get pods gets slow and it’s a confusing thing to debug when the cause is a button you clicked 200 times.

Don’t let the endpoint take a CronJob name from the request. Allowlist it. Otherwise “refresh this dashboard” is a remote workload-execution primitive.

Role, not ClusterRole, and resist widening it. The permission that makes the next feature easy is the permission you’ll regret in the incident review.

A refresh button invites impatience. I click it, watch nothing happen for eight seconds, and click it again. Disable the button while a Job for that dashboard is already running — otherwise you get three concurrent crawls of the same source, which is both wasteful and a good way to get rate-limited by somebody’s auction site.

backoff_limit=0 for anything a human triggered. Silent retries plus a success indicator is the worst possible combination.

Where this sits

Six dashboards, one narrowly-scoped ServiceAccount, on-demand Jobs created from the CronJob templates that already existed, and sortable tables everywhere.

The infrastructure work here was maybe an afternoon. The valuable part was what the sorted data said: that I’d spent a year optimising the wrong number, and that the fix was already sitting in two date columns I’d added for bookkeeping reasons.