From 7befc8f0d73852f2983cbc11d8ba7e54912789e9 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 28 May 2026 16:32:56 -0400 Subject: [PATCH] Add drop-folder ingestion and scoped uploader IAM user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Phase 1 ingestion. Add a least-privilege IAM user (apm-wo-drop-uploader) to the pipeline stack, scoped to s3:PutObject on the raw/ prefix only — the local launchd uploader authenticates as this user via a dedicated profile, so a laptop credential leak cannot read, list, or touch the analytics data. Replace the scaffold uploader stub with the hardened stampli-pattern script (lockfile, logging, timestamped archive, notifications, settle delay) and align names to the convention (~/apm-wo-drop, ~/.local/bin, com.seahaven.apm-wo-uploader). The plist sets PATH/HOME because launchd runs with a stripped environment and otherwise cannot find aws. The exports bucket already shipped in the Phase 0 scaffold, so the code delta here is the uploader identity and tooling. --- README.md | 23 +++++++-- cdk/stacks/pipeline_stack.py | 18 +++++++ scripts/apm-wo-uploader.sh | 59 ++++++++++++++++++++++ scripts/com.seahaven.apm-wo-drop.plist | 31 ------------ scripts/com.seahaven.apm-wo-uploader.plist | 44 ++++++++++++++++ scripts/drop_folder_upload.sh | 29 ----------- 6 files changed, 141 insertions(+), 63 deletions(-) create mode 100755 scripts/apm-wo-uploader.sh delete mode 100644 scripts/com.seahaven.apm-wo-drop.plist create mode 100644 scripts/com.seahaven.apm-wo-uploader.plist delete mode 100755 scripts/drop_folder_upload.sh diff --git a/README.md b/README.md index 4be7625..f8e0788 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,27 @@ No secrets in Lambda environment variables. The export reaches S3 by **direct upload or a local drop-folder**, never SES/email. - **Direct:** `aws s3 cp ./export.xlsx s3://apm-wo-analysis-exports-328440206208/raw/` -- **Drop-folder (optional):** the launchd agent in `scripts/`. Both the script and - the watched folder must live **outside `~/Documents`** (macOS TCC sandbox). +- **Drop-folder (optional zero-touch):** a launchd agent (`scripts/apm-wo-uploader.sh` + + `scripts/com.seahaven.apm-wo-uploader.plist`) that watches `~/apm-wo-drop/`, + uploads new `.xlsx`/`.csv` files to `raw/`, and archives them to `uploaded/`. + It uploads with the scoped `apm-wo-drop` AWS profile (IAM user + `apm-wo-drop-uploader` — `s3:PutObject` on `raw/*` only). -The classifier Lambda is S3-triggered on the `raw/` prefix regardless of path. + Install (the runnable copy **must** live outside `~/Documents` — macOS TCC + sandbox; a repo-path script fails silently with `LastExitStatus=32256`): + ```bash + install -d "$HOME/.local/bin" "$HOME/apm-wo-drop" + cp scripts/apm-wo-uploader.sh "$HOME/.local/bin/apm-wo-uploader.sh" + chmod +x "$HOME/.local/bin/apm-wo-uploader.sh" + cp scripts/com.seahaven.apm-wo-uploader.plist "$HOME/Library/LaunchAgents/" + launchctl load -w "$HOME/Library/LaunchAgents/com.seahaven.apm-wo-uploader.plist" + ``` + Re-copy the script to `~/.local/bin` after editing the repo source. Configure + the profile once with the uploader's access key: + `aws configure --profile apm-wo-drop`. + +The classifier Lambda is S3-triggered on the `raw/` prefix regardless of path +(added in Phase 2 — uploads currently land in `raw/` and wait). ## Deployment diff --git a/cdk/stacks/pipeline_stack.py b/cdk/stacks/pipeline_stack.py index 014d413..77f7bd2 100644 --- a/cdk/stacks/pipeline_stack.py +++ b/cdk/stacks/pipeline_stack.py @@ -14,6 +14,9 @@ from aws_cdk import ( RemovalPolicy, Stack, ) +from aws_cdk import ( + aws_iam as iam, +) from aws_cdk import ( aws_s3 as s3, ) @@ -43,6 +46,21 @@ class PipelineStack(Stack): ], ) + # Phase 1 — least-privilege identity for the local drop-folder uploader. + # Scoped to s3:PutObject on raw/* only. The access key is created + # out-of-band (aws iam create-access-key) and stored in the local + # ~/.aws/credentials profile `apm-wo-drop` — never in CloudFormation. + self.drop_uploader = iam.User( + self, "DropUploader", user_name="apm-wo-drop-uploader" + ) + self.drop_uploader.add_to_policy( + iam.PolicyStatement( + sid="PutRawExportsOnly", + actions=["s3:PutObject"], + resources=[self.exports_bucket.arn_for_objects("raw/*")], + ) + ) + # Phase 2 — classifier Lambda, S3-triggered on the raw/ prefix. TODO # Phase 3 — Glue database `apm_wo_analysis` + Athena workgroup # (partition projection on dt; no crawler). TODO diff --git a/scripts/apm-wo-uploader.sh b/scripts/apm-wo-uploader.sh new file mode 100755 index 0000000..e009786 --- /dev/null +++ b/scripts/apm-wo-uploader.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# apm-wo-analysis local drop-folder uploader (optional zero-touch ingestion). +# +# Mirrors the proven stampli-drop-folder pattern. launchd invokes the INSTALLED +# copy at ~/.local/bin/apm-wo-uploader.sh, which must live OUTSIDE ~/Documents: +# macOS TCC denies launchd read access to ~/Documents, ~/Desktop, ~/Downloads, +# and a repo-path script fails silently with LastExitStatus=32256. Re-copy this +# source to ~/.local/bin after editing it. +# +# Uploads new .xlsx/.csv exports to the raw/ prefix using the scoped `apm-wo-drop` +# profile (IAM user apm-wo-drop-uploader — s3:PutObject on raw/ only), then +# archives them locally. The classifier Lambda is S3-triggered from raw/. +set -euo pipefail + +DROP_DIR="$HOME/apm-wo-drop" +UPLOADED_DIR="$DROP_DIR/uploaded" +LOG_FILE="$DROP_DIR/.upload.log" +BUCKET="apm-wo-analysis-exports-328440206208" +PROFILE="${APM_WO_AWS_PROFILE:-apm-wo-drop}" + +mkdir -p "$UPLOADED_DIR" +exec >> "$LOG_FILE" 2>&1 + +# Single-flight: WatchPaths can fire several times for one save. +LOCK_DIR="$DROP_DIR/.upload.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$(date '+%Y-%m-%dT%H:%M:%S') skipping — another run holds the lock" + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +# Let the file finish writing before uploading. +sleep 2 + +shopt -s nullglob +uploaded_count=0 +failed_count=0 + +for f in "$DROP_DIR"/*.xlsx "$DROP_DIR"/*.csv; do + [ -f "$f" ] || continue + name=$(basename "$f") + ts=$(date '+%Y%m%d-%H%M%S') + + echo "$(date '+%Y-%m-%dT%H:%M:%S') uploading $name" + if aws --profile "$PROFILE" s3 cp "$f" "s3://$BUCKET/raw/$name"; then + mv "$f" "$UPLOADED_DIR/$ts-$name" + echo "$(date '+%Y-%m-%dT%H:%M:%S') OK -> uploaded/$ts-$name" + uploaded_count=$((uploaded_count + 1)) + osascript -e "display notification \"Uploaded $name\" with title \"APM WO Uploader\"" 2>/dev/null || true + else + echo "$(date '+%Y-%m-%dT%H:%M:%S') FAIL $name" + failed_count=$((failed_count + 1)) + osascript -e "display notification \"Failed to upload $name — see .upload.log\" with title \"APM WO Uploader\" sound name \"Basso\"" 2>/dev/null || true + fi +done + +if [ $uploaded_count -eq 0 ] && [ $failed_count -eq 0 ]; then + echo "$(date '+%Y-%m-%dT%H:%M:%S') folder change triggered but no .xlsx/.csv files found" +fi diff --git a/scripts/com.seahaven.apm-wo-drop.plist b/scripts/com.seahaven.apm-wo-drop.plist deleted file mode 100644 index 778c224..0000000 --- a/scripts/com.seahaven.apm-wo-drop.plist +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - Label - com.seahaven.apm-wo-drop - ProgramArguments - - /bin/bash - /Users//Library/Application Support/seahaven/apm-wo-drop/upload.sh - - WatchPaths - - /Users//APM-WO-Drop - - StandardOutPath - /tmp/apm-wo-drop.out.log - StandardErrorPath - /tmp/apm-wo-drop.err.log - - diff --git a/scripts/com.seahaven.apm-wo-uploader.plist b/scripts/com.seahaven.apm-wo-uploader.plist new file mode 100644 index 0000000..1751591 --- /dev/null +++ b/scripts/com.seahaven.apm-wo-uploader.plist @@ -0,0 +1,44 @@ + + + + + + Label + com.seahaven.apm-wo-uploader + ProgramArguments + + /bin/bash + /Users/adammoussa/.local/bin/apm-wo-uploader.sh + + WatchPaths + + /Users/adammoussa/apm-wo-drop + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/adammoussa + + StandardOutPath + /tmp/apm-wo-uploader.out.log + StandardErrorPath + /tmp/apm-wo-uploader.err.log + + diff --git a/scripts/drop_folder_upload.sh b/scripts/drop_folder_upload.sh deleted file mode 100755 index 851d58d..0000000 --- a/scripts/drop_folder_upload.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# apm-wo-analysis local drop-folder uploader (optional zero-touch ingestion). -# -# Mirrors the stampli-drop-folder pattern. When deployed, BOTH this script and -# the watched folder must live OUTSIDE ~/Documents (macOS TCC sandbox — see the -# macos-tcc-launchd memory). Suggested install locations: -# script: ~/Library/Application Support/seahaven/apm-wo-drop/upload.sh -# folder: ~/APM-WO-Drop -# -# Triggered by the launchd agent (scripts/com.seahaven.apm-wo-drop.plist) on a -# WatchPaths change. Uploads each new export to the raw/ prefix, then moves it to -# a local processed/ subfolder. Uses a least-privilege local AWS profile scoped -# to s3:PutObject on raw/ only. The classifier Lambda is S3-triggered from there. -set -euo pipefail - -DROP_DIR="${APM_WO_DROP_DIR:-$HOME/APM-WO-Drop}" -PROCESSED_DIR="$DROP_DIR/processed" -BUCKET="apm-wo-analysis-exports-328440206208" -PROFILE="${APM_WO_AWS_PROFILE:-apm-wo-drop}" - -mkdir -p "$PROCESSED_DIR" - -shopt -s nullglob -for f in "$DROP_DIR"/*.xlsx "$DROP_DIR"/*.csv; do - [ -e "$f" ] || continue - name="$(basename "$f")" - aws --profile "$PROFILE" s3 cp "$f" "s3://${BUCKET}/raw/${name}" - mv "$f" "$PROCESSED_DIR/$name" -done