diff --git a/.gitignore b/.gitignore index 6217176..7ecfeb1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ bootstrap.build/ .remember/ .mock-runtime-api-port _site/ +.clj-kondo/ +.lsp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be7834..a1387a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. - Initial public release preparation. - x86_64 builds: `LAMBDA_ARCH=x86_64 jolt image` (default stays `arm64`); `jolt deploy` reads the architecture from `dist/bootstrap`'s ELF header and passes it on both create and update. Thanks to [@codegod100](https://github.com/codegod100) for this contribution ([#2](https://github.com/b12n-oss/lambda-mvp-jlt/pull/2)), verified end to end on x86_64 Linux and, separately, against the project's own arm64 default on Apple Silicon. - `jolt demo`: `image` + `deploy` + `invoke` in one command, with every prerequisite (tools, AWS credentials and region, Docker access, emulation for `LAMBDA_ARCH`) checked before the build starts. Also from [#2](https://github.com/b12n-oss/lambda-mvp-jlt/pull/2). +- LocalStack support: `jolt localstack:demo` runs deploy + invoke against a local LocalStack container — no AWS account, credentials, or cost. One switch, `LAMBDA_ENDPOINT_URL`, redirects every aws CLI call this repo makes to the emulator (dummy credentials included); unset, everything behaves exactly as before. See `docs/guide/localstack.md`. ## [0.1.0] diff --git a/README.md b/README.md index 36a9a3f..fcb778e 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,12 @@ See `docs/guide/runtime-api-loop.md` for the loop's design notes and ```sh jolt probe # offline e2e: mock Runtime API + demo handler (no AWS, no Docker) -jolt test # run script/bench.clj's unit tests (no AWS, no Docker) +jolt test # run the unit tests (no AWS, no Docker) jolt demo # image + deploy + invoke in one go, prerequisites checked first jolt image # AL2023 Docker build -> dist/bootstrap + dist/lambda.zip (arm64 default) jolt deploy # idempotent: create/update the IAM role + Lambda function jolt invoke # single ad-hoc invoke, prints the response body + REPORT line +jolt localstack:demo # LocalStack variant: start it, deploy, invoke -- no AWS account jolt bench # cold/warm boot-time comparison across memory tiers jolt teardown # delete the function + role when you're done ``` @@ -91,6 +92,26 @@ binary. `jolt deploy`'s IAM role (`lambda-mvp-jlt-role`) and function `BENCH_MEMORY_TIERS` (default `2048,3008`) and `BENCH_WARM_SAMPLES` (default `5`). +## Running locally against LocalStack + +Every AWS-touching task here also runs against +[LocalStack](https://localstack.cloud) instead of a real account: + +```sh +jolt image # as usual: build dist/lambda.zip first +jolt localstack:demo # start LocalStack + deploy + invoke, no AWS account +jolt localstack:status # is the container up, is the edge answering? +jolt localstack:stop # stop + remove the container +``` + +The switch is one env var: `LAMBDA_ENDPOINT_URL` (set to +`http://localhost:4566` by the wrapper). Set, it redirects every `aws` call +this repo's scripts make to that endpoint and skips the real-AWS credential +preflight; unset, behavior is exactly as before. See +[docs/guide/localstack.md](docs/guide/localstack.md) for details — including +why you shouldn't trust LocalStack numbers for the cold/warm boot question +`jolt bench` exists to answer. + ## Cold vs. warm boot time See [`docs/guide/cold-warm-boot.md`](docs/guide/cold-warm-boot.md) for what diff --git a/bb.edn b/bb.edn index 2ddc637..b453935 100644 --- a/bb.edn +++ b/bb.edn @@ -49,8 +49,9 @@ (println " jolt image AL2023 Docker build -> dist/bootstrap + dist/lambda.zip") (println " jolt deploy idempotent: create/update the IAM role + Lambda function") (println " jolt invoke single ad-hoc invoke, prints response + REPORT line") - (println " jolt bench cold/warm boot-time comparison across memory tiers") - (println " jolt test run script/bench.clj's unit tests") + (println " jolt bench cold/warm boot-time comparison across memory tiers") + (println " jolt localstack:demo LocalStack variant: start it, deploy, invoke -- no AWS account") + (println " jolt test run the unit tests (bench + localstack helpers)") (println " jolt teardown delete the function + role") (println " jolt clean remove build artifacts") (println " jolt site:build build the docs site into _site/ (needs a docs-engine checkout)") @@ -63,21 +64,25 @@ (println "No task hardcodes an AWS profile, account, or region -- see README.md"))} probe {:doc "offline e2e: mock Runtime API on an OS-assigned port + the demo handler" - :task (let [port-file ".mock-runtime-api-port"] - (when (.exists (java.io.File. port-file)) (.delete (java.io.File. port-file))) - (let [srv (p/process ["python3" "tools/mock_runtime_api.py"] - {:inherit true})] - (loop [n 0] - (when (and (not (.exists (java.io.File. port-file))) (< n 50)) - (Thread/sleep 100) - (recur (inc n)))) - (if-not (.exists (java.io.File. port-file)) - (do (println "probe: mock server did not publish its port -- aborting") - (System/exit 1)) - (let [port (.trim (slurp port-file))] - (p/shell {:extra-env {"AWS_LAMBDA_RUNTIME_API" (str "127.0.0.1:" port)}} - "joltc run -m net.b12n.lambda-mvp.main") - (System/exit (:exit (deref srv)))))))} + :task (do (when-not (fs/which "joltc") + (println "probe: joltc is not on PATH -- install jolt with the compiler") + (println " (brew install jolt-lang/jolt/jolt) and retry.") + (System/exit 1)) + (let [port-file ".mock-runtime-api-port"] + (when (.exists (java.io.File. port-file)) (.delete (java.io.File. port-file))) + (let [srv (p/process ["python3" "tools/mock_runtime_api.py"] + {:inherit true})] + (loop [n 0] + (when (and (not (.exists (java.io.File. port-file))) (< n 50)) + (Thread/sleep 100) + (recur (inc n)))) + (if-not (.exists (java.io.File. port-file)) + (do (println "probe: mock server did not publish its port -- aborting") + (System/exit 1)) + (let [port (.trim (slurp port-file))] + (p/shell {:extra-env {"AWS_LAMBDA_RUNTIME_API" (str "127.0.0.1:" port)}} + "joltc run -m net.b12n.lambda-mvp.main") + (System/exit (:exit (deref srv))))))))} image {:doc "build bootstrap + lambda.zip in an amazonlinux:2023 container (LAMBDA_ARCH, default arm64)" :task (let [jolt-version (System/getenv "JOLT_VERSION") @@ -114,8 +119,21 @@ bench {:doc "cold/warm boot-time comparison across memory tiers" :task (p/shell "bb" "script/bench_run.clj")} - test {:doc "run script/bench.clj's unit tests" - :task (p/shell "bb" "test/bench_test.clj")} + localstack:start {:doc "start a LocalStack container (edge on localhost:4566) for local, no-AWS deploys" + :task (p/shell "bb" "script/localstack_run.clj" "start")} + + localstack:demo {:doc "start LocalStack (if needed), deploy the built zip to it, invoke -- no AWS account touched" + :task (p/shell "bb" "script/localstack_run.clj" "demo")} + + localstack:stop {:doc "stop + remove the LocalStack container" + :task (p/shell "bb" "script/localstack_run.clj" "stop")} + + localstack:status {:doc "is the LocalStack container up, and is its edge answering?" + :task (p/shell "bb" "script/localstack_run.clj" "status")} + + test {:doc "run the unit tests (bench + localstack helpers)" + :task (do (p/shell "bb" "test/localstack_test.clj") + (p/shell "bb" "test/bench_test.clj"))} site:build {:doc "build the docs site into _site/ (needs a docs-engine checkout)" :task (site-task "build")} diff --git a/docs/guide/index.md b/docs/guide/index.md index fa28115..e64b7b3 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -25,6 +25,7 @@ Every AWS-touching command in this repo relies entirely on the caller's own `aws | [The Runtime API loop](runtime-api-loop.md) | The Lambda custom-runtime contract and this project's implementation | | [Building on Amazon Linux 2023](al2023-build.md) | The glibc constraint, the from-source build recipe, arm64 | | [Cold vs. warm boot](cold-warm-boot.md) | What `jolt bench` measures and how to reproduce the comparison | +| [Running against LocalStack](localstack.md) | Deploy/invoke/teardown with no AWS account, via `LAMBDA_ENDPOINT_URL` | | [Contributing](contributing.md) | Build, test, and PR conventions | ## Find your scenario @@ -34,6 +35,7 @@ Every AWS-touching command in this repo relies entirely on the caller's own `aws | "I want to try this out" | Getting started | | "I want to understand how it works" | Architecture, then The Runtime API loop and Building on Amazon Linux 2023 | | "I want to measure cold/warm boot time on my own account" | Getting started, then Cold vs. warm boot | +| "I want the full lifecycle without an AWS account" | Running against LocalStack | | "I want to contribute a change" | Contributing | ## See also diff --git a/docs/guide/localstack.md b/docs/guide/localstack.md new file mode 100644 index 0000000..e003653 --- /dev/null +++ b/docs/guide/localstack.md @@ -0,0 +1,92 @@ +# Running against LocalStack + +Everything in this repo that touches AWS (`jolt deploy`, `jolt invoke`, +`jolt teardown`, `jolt bench`, `jolt demo`) can run against +[LocalStack](https://localstack.cloud) instead of a real account — useful for +trying the full deploy/invoke lifecycle with zero AWS credentials, zero cost, +and zero risk to a real account. The Runtime API loop, the handler, and the +zip are exactly the same artifacts real AWS gets; only the control plane +(`lambda create-function`, `invoke`, IAM) is emulated. + +## Quickstart + +```sh +jolt image # unchanged: build dist/lambda.zip first +jolt localstack:demo # start LocalStack + deploy + invoke, no AWS account +jolt localstack:stop # stop + remove the container when done +``` + +`jolt localstack:demo` starts a detached `localstack/localstack` container +(`lambda-mvp-jlt-localstack`, edge on `http://localhost:4566`, host docker +socket mounted), waits for the edge to answer, then runs this repo's own +`deploy` and `invoke` tasks unchanged. `jolt localstack:status` reports +whether the container is up and the edge is answering. + +The Lambda function containers LocalStack runs are created through the host's +docker daemon (that's why the socket is mounted), so the function executes +real `bootstrap` + `lib/` from your `dist/lambda.zip`. + +## The switch: `LAMBDA_ENDPOINT_URL` + +Nothing about the default behavior changes until you set +`LAMBDA_ENDPOINT_URL`. Unset (or blank) means real AWS, exactly as before. +Set, it does two things to every `aws` CLI call the scripts make: + +- `--endpoint-url ` is injected as a global option, and +- LocalStack's documented dummy credentials (`test`/`test`, region + `us-east-1`) are exported — unless you've set `AWS_ACCESS_KEY_ID` / + `AWS_SECRET_ACCESS_KEY` / `AWS_DEFAULT_REGION` yourself, which then win. + +The real-AWS credential/region preflight in `demo`/`deploy`/`invoke`/`bench` +is skipped when the endpoint is set, so no aws CLI configuration is needed at +all for the LocalStack path. + +You can drive it by hand, without the wrapper tasks: + +```sh +export LAMBDA_ENDPOINT_URL=http://localhost:4566 +export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 +aws --endpoint-url $LAMBDA_ENDPOINT_URL lambda list-functions # anything, really +bb script/aws_lifecycle.clj deploy # or jolt deploy -- same code path +bb script/aws_lifecycle.clj invoke +bb script/aws_lifecycle.clj teardown +``` + +(Or start your own LocalStack: `docker run -d -p 4566:4566 +-v /var/run/docker.sock:/var/run/docker.sock localstack/localstack`.) + +Note on versions: LocalStack's March 2026 licensing change means +`localstack/localstack:latest` (and anything newer) refuses to start without +`LOCALSTACK_AUTH_TOKEN`, even for community features. This repo therefore +pins `localstack/localstack:4.13.1`, the last tag that runs without an +account. If you have a token (the free tier works), export +`LOCALSTACK_AUTH_TOKEN` and optionally `LOCALSTACK_IMAGE` (e.g. +`localstack/localstack:latest`) and the wrapper tasks will pass both to the +container. + +## What LocalStack is good for here, and what it isn't + +Good: + +- End-to-end confidence in the deploy/invoke/teardown lifecycle (packaging, + `provided.al2023`, handler wiring, response/log plumbing) without an + account. +- Fast iteration on `aws_lifecycle.clj` itself. +- CI smoke tests. + +Not good: + +- **Cold/warm boot numbers.** `jolt bench` runs against LocalStack if the + endpoint is set, but LocalStack executes your function in a plain docker + container on your machine — no Firecracker microVM, no Lambda memory-tier + CPU credits, and on Apple Silicon possibly under emulation. The numbers are + meaningless for the cold-vs-warm question; use a real account for that + (that's what `jolt bench` exists for). +- IAM fidelity. LocalStack's IAM is permissive; a trust policy that works + here may still be rejected by real AWS. + +## Requirements + +- Docker (running), for both the LocalStack container and the function + containers it spawns. +- The `aws` CLI on PATH (version 2), as for the real-AWS path. diff --git a/script/aws_lifecycle.clj b/script/aws_lifecycle.clj index 59643b2..3649cae 100644 --- a/script/aws_lifecycle.clj +++ b/script/aws_lifecycle.clj @@ -11,14 +11,30 @@ [cheshire.core :as json] [clojure.string :as str])) +;; bb script/x.clj does not put the project root on the classpath, so the +;; helper ns is loaded via load-file and referenced fully qualified (same +;; pattern as script/bench_run.clj loading script/bench.clj). +(load-file "script/localstack.clj") + (def function-name (or (System/getenv "LAMBDA_MVP_FUNCTION_NAME") "lambda-mvp-jlt")) + +(def localstack-endpoint + "Non-nil when LAMBDA_ENDPOINT_URL is set: every aws call below is redirected + there (normally a LocalStack on localhost) with dummy credentials, so the + same lifecycle runs against a local emulator instead of a real account." + (script.localstack/endpoint (System/getenv))) + (def role-name (str function-name "-role")) (def zip-path "dist/lambda.zip") (def bootstrap-path "dist/bootstrap") (def policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole") (defn- sh [& args] - (let [{:keys [exit out err]} (apply p/shell {:out :string :err :string :continue true} args)] + (let [{:keys [exit out err]} + (apply p/shell {:out :string :err :string :continue true + :extra-env (script.localstack/credentials-env + (System/getenv) localstack-endpoint)} + (script.localstack/with-endpoint args localstack-endpoint))] {:exit exit :out out :err err})) (defn- die! [& msg] @@ -44,11 +60,14 @@ "Fail fast with a clear message if the aws CLI has no usable credentials/region, rather than letting a later call fail obscurely." [] - (let [{:keys [exit err]} (sh "aws" "sts" "get-caller-identity" "--output" "json")] - (when-not (zero? exit) - (die! "aws CLI has no usable credentials/region." - "Set AWS_PROFILE/AWS_REGION or run `aws configure`, then retry.\n" - (str/trim (or err "")))))) + (if localstack-endpoint + (println "lambda-mvp-jlt: LAMBDA_ENDPOINT_URL set -- using" localstack-endpoint + "; skipping the real-AWS identity check") + (let [{:keys [exit err]} (sh "aws" "sts" "get-caller-identity" "--output" "json")] + (when-not (zero? exit) + (die! "aws CLI has no usable credentials/region." + "Set AWS_PROFILE/AWS_REGION or run `aws configure`, then retry.\n" + (str/trim (or err ""))))))) (def ^:private trust-policy (json/generate-string diff --git a/script/bench_run.clj b/script/bench_run.clj index 13cab5d..817c629 100644 --- a/script/bench_run.clj +++ b/script/bench_run.clj @@ -20,7 +20,11 @@ (Integer/parseInt (or (System/getenv "BENCH_WARM_SAMPLES") "5"))) (defn- sh [& args] - (let [{:keys [exit out err]} (apply p/shell {:out :string :err :string :continue true} args)] + (let [{:keys [exit out err]} + (apply p/shell {:out :string :err :string :continue true + :extra-env (script.localstack/credentials-env + (System/getenv) localstack-endpoint)} + (script.localstack/with-endpoint args localstack-endpoint))] {:exit exit :out out :err err})) (defn- die! [& msg] @@ -31,11 +35,14 @@ "Fail fast with a clear message if the aws CLI has no usable credentials/region, rather than letting a later call fail obscurely." [] - (let [{:keys [exit err]} (sh "aws" "sts" "get-caller-identity" "--output" "json")] - (when-not (zero? exit) - (die! "aws CLI has no usable credentials/region." - "Set AWS_PROFILE/AWS_REGION or run `aws configure`, then retry.\n" - (str/trim (or err "")))))) + (if localstack-endpoint + (println "lambda-mvp-jlt: LAMBDA_ENDPOINT_URL set -- using" localstack-endpoint + "; skipping the real-AWS identity check") + (let [{:keys [exit err]} (sh "aws" "sts" "get-caller-identity" "--output" "json")] + (when-not (zero? exit) + (die! "aws CLI has no usable credentials/region." + "Set AWS_PROFILE/AWS_REGION or run `aws configure`, then retry.\n" + (str/trim (or err ""))))))) (defn- set-memory! [tier] (let [{:keys [exit err]} (sh "aws" "lambda" "update-function-configuration" diff --git a/script/demo.clj b/script/demo.clj index 9a0caa7..a7865b4 100644 --- a/script/demo.clj +++ b/script/demo.clj @@ -13,7 +13,11 @@ (def base-image (or (System/getenv "BASE_IMAGE") "public.ecr.aws/amazonlinux/amazonlinux:2023")) (defn- sh [& args] - (let [{:keys [exit out err]} (apply p/shell {:out :string :err :string :continue true} args)] + (let [{:keys [exit out err]} + (apply p/shell {:out :string :err :string :continue true + :extra-env (script.localstack/credentials-env + (System/getenv) localstack-endpoint)} + (script.localstack/with-endpoint args localstack-endpoint))] {:exit exit :out out :err err})) (defn- die! [& msg] @@ -27,6 +31,12 @@ "-- see Prerequisites in docs/guide/getting-started.md.")))) (defn- require-aws! [] + (if localstack-endpoint + (println "lambda-mvp-jlt: LAMBDA_ENDPOINT_URL set -- using" localstack-endpoint + "; skipping the real-AWS credential/region checks") + (require-real-aws!))) + +(defn- require-real-aws! [] (let [region (some not-empty [(System/getenv "AWS_REGION") (System/getenv "AWS_DEFAULT_REGION") (str/trim (:out (sh "aws" "configure" "get" "region")))])] diff --git a/script/localstack.clj b/script/localstack.clj new file mode 100644 index 0000000..731f5fb --- /dev/null +++ b/script/localstack.clj @@ -0,0 +1,84 @@ +(ns script.localstack + "Pure helpers for running the demo against LocalStack instead of real AWS. + No I/O here -- see script/localstack_run.clj for the orchestration. + + The single switch is LAMBDA_ENDPOINT_URL: unset (or blank) means real AWS + and every helper here is a no-op; set means every aws CLI call made by + this repo's scripts is redirected to that endpoint (normally LocalStack's + http://localhost:4566) with LocalStack's dummy credentials, so nothing + touches a real account." + (:require [clojure.string :as str])) + +(def image "localstack/localstack:4.13.1") + +(def container-name "lambda-mvp-jlt-localstack") + +(def default-endpoint "http://localhost:4566") + +(defn endpoint + "The override endpoint from LAMBDA_ENDPOINT_URL (trimmed), or nil when unset + or blank -- nil meaning real AWS, the repo's default behavior." + [env] + (some-> (get env "LAMBDA_ENDPOINT_URL") + str/trim + not-empty)) + +(defn with-endpoint + "Insert --endpoint-url as an aws CLI global option (must sit before the + service subcommand) when endpoint is non-nil; otherwise argv unchanged. + Non-aws argv (docker, bb, ...) passes through untouched, so a sh helper + that builds mixed commands can call this unconditionally." + [argv endpoint] + (if (and endpoint (= "aws" (first argv))) + (into ["aws" "--endpoint-url" endpoint] (rest argv)) + argv)) + +(defn credentials-env + "Env overrides for LocalStack's documented dummy credentials. Caller-supplied + values win, so an explicit AWS_DEFAULT_REGION (or a real profile's + credentials) is never silently clobbered. Returns nil when endpoint is nil + (real AWS -- leave the environment alone)." + [env endpoint] + (if (some-> endpoint str/trim not-empty) + (merge {"AWS_ACCESS_KEY_ID" "test" + "AWS_SECRET_ACCESS_KEY" "test" + "AWS_DEFAULT_REGION" "us-east-1"} + (select-keys env ["AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" + "AWS_DEFAULT_REGION" "AWS_REGION"])) + {})) + +(defn docker-run-argv + "docker run argv for a LocalStack container: detached, named, edge port + 4566 published, and the docker socket mounted so LocalStack's Lambda + provider can run function containers through the host daemon (the standard + localstack CLI setup; without the mount, lambda emits errors about + reaching the docker daemon). + + The default image is pinned to the last tag that runs without a license: + localstack/localstack >= 2026.3 (including latest) exits 55 at startup + unless LOCALSTACK_AUTH_TOKEN is set -- pass one via env and/or override the + image with LOCALSTACK_IMAGE to use a newer LocalStack." + ([] (docker-run-argv (System/getenv))) + ([env] + (let [auth-token (some-> (get env "LOCALSTACK_AUTH_TOKEN") str/trim not-empty) + image (or (some-> (get env "LOCALSTACK_IMAGE") str/trim not-empty) + image)] + (vec (concat ["docker" "run" "-d" "--name" container-name + "-p" "4566:4566" + "-v" "/var/run/docker.sock:/var/run/docker.sock"] + (when auth-token ["-e" (str "LOCALSTACK_AUTH_TOKEN=" auth-token)]) + [image]))))) + +(let [required {"LAMBDA_ENDPOINT_URL" default-endpoint + "AWS_ACCESS_KEY_ID" "test" + "AWS_SECRET_ACCESS_KEY" "test" + "AWS_DEFAULT_REGION" "us-east-1"}] + (defn demo-env + "Environment map for running this repo's own bb tasks (deploy/invoke/ + teardown) against LocalStack. Computed at load like the env-reading + defaults in the other scripts." + [] + (merge required + (select-keys (System/getenv) + ["AWS_DEFAULT_REGION" "AWS_REGION" "LAMBDA_MVP_FUNCTION_NAME"])))) + diff --git a/script/localstack_run.clj b/script/localstack_run.clj new file mode 100644 index 0000000..6f575e1 --- /dev/null +++ b/script/localstack_run.clj @@ -0,0 +1,131 @@ +(ns script.localstack-run + "`jolt localstack:start|stop|status|demo`: run the whole lambda-mvp-jlt + lifecycle against a LocalStack container instead of a real AWS account. + The lifecycle itself is not re-implemented here -- start brings up + LocalStack, then everything else runs this repo's own tasks with + LAMBDA_ENDPOINT_URL exported, so deploy/invoke/teardown/bench all hit the + emulator through the same code path they use for real AWS." + (:require [babashka.process :as p] + [cheshire.core :as json] + [clojure.string :as str])) + +(load-file "script/localstack.clj") + +;; bb script/x.clj does not put the project root on the classpath, so the +;; helper ns is loaded via load-file and referenced fully qualified (same +;; pattern as script/bench_run.clj). + +(defn- sh [& args] + (let [{:keys [exit out err]} (apply p/shell {:out :string :err :string :continue true} args)] + {:exit exit :out out :err err})) + +(defn- die! [& msg] + (binding [*out* *err*] (apply println "lambda-mvp-jlt:" msg)) + (System/exit 1)) + +(defn container-running? [] + (let [{:keys [exit out]} + (sh "docker" "inspect" "-f" "{{.State.Running}}" script.localstack/container-name)] + (and (zero? exit) + (= "true" (str/trim out))))) + +(defn container-exists? [] + (zero? (:exit (sh "docker" "inspect" script.localstack/container-name)))) + +(defn ready? + "LocalStack answers once its edge service is up (Service endpoint healthy). + Dummy credentials are required -- the aws CLI refuses to even send an + unsigned request without some credentials configured." + [] + (let [{:keys [exit out]} + (p/shell {:out :string :err :string :continue true + :extra-env (script.localstack/credentials-env + {} script.localstack/default-endpoint)} + "aws" "--endpoint-url" script.localstack/default-endpoint + "sts" "get-caller-identity" "--output" "json" + "--no-cli-pager")] + (and (zero? exit) + (boolean (seq out))))) + +(defn wait-until-ready! + "Poll the edge endpoint until it answers or n tries elapse." + ([] (wait-until-ready! 30)) + ([tries] + (loop [n 0] + (cond (ready?) :ready + (>= n tries) (die! "LocalStack not ready after" tries "polls -- see" + (str "docker logs " script.localstack/container-name)) + :else (do (print ".") (flush) (Thread/sleep 1000) (recur (inc n))))))) + +(defn start! [] + (if (container-running?) + (do (println "lambda-mvp-jlt:" script.localstack/container-name "already running") + (print "lambda-mvp-jlt: waiting for the edge service") + (wait-until-ready!) + (println) + (println "lambda-mvp-jlt: LocalStack ready at" script.localstack/default-endpoint)) + (do + (when (container-exists?) + (println "lambda-mvp-jlt: removing stopped container" script.localstack/container-name) + (sh "docker" "rm" script.localstack/container-name)) + (println "lambda-mvp-jlt: starting LocalStack" + (str "(" script.localstack/image ", http://localhost:4566)")) + (let [{:keys [exit err]} (apply p/shell {:continue true} (script.localstack/docker-run-argv))] + (when-not (zero? exit) (die! "docker run failed:" err))) + (print "lambda-mvp-jlt: waiting for the edge service") + (wait-until-ready!) + (println) + (println "lambda-mvp-jlt: LocalStack ready at" script.localstack/default-endpoint + "-- LAMBDA_ENDPOINT_URL=" script.localstack/default-endpoint + "redirects all aws calls in this repo to it")))) + +(defn stop! [] + (cond + (container-running?) + (do (println "lambda-mvp-jlt: stopping" script.localstack/container-name) + (let [{:keys [exit err]} (sh "docker" "stop" script.localstack/container-name)] + (when-not (zero? exit) (die! "docker stop failed:" err))) + (sh "docker" "rm" script.localstack/container-name) + (println "lambda-mvp-jlt: stopped and removed")) + + (container-exists?) + (do (sh "docker" "rm" script.localstack/container-name) + (println "lambda-mvp-jlt: removed stopped container")) + + :else (println "lambda-mvp-jlt: nothing to stop"))) + +(defn status! [] + (cond + (container-running?) + (println "lambda-mvp-jlt:" script.localstack/container-name "running at" + script.localstack/default-endpoint + (if (ready?) "(edge healthy)" "(edge NOT answering yet)")) + + (container-exists?) + (println "lambda-mvp-jlt:" script.localstack/container-name "exists but stopped") + + :else (println "lambda-mvp-jlt: no" script.localstack/container-name "container"))) + +(defn- run-task! [task & args] + (apply p/shell {:extra-env (script.localstack/demo-env) :continue true} + "bb" task (remove nil? args))) + +(defn demo! [] + (start!) + (let [{:keys [exit]} (run-task! "deploy")] + (when-not (zero? exit) (die! "deploy failed"))) + (run-task! "invoke") + (println "\nlambda-mvp-jlt: done. `jolt localstack:stop` removes the container.")) + +(defn -main [& args] + (case (first args) + "start" (start!) + "stop" (stop!) + "status" (status!) + "demo" (demo!) + "invoke" (run-task! "invoke") + "deploy" (run-task! "deploy") + "teardown" (run-task! "teardown") + (die! "usage: localstack_run.clj start|stop|status|demo|deploy|invoke|teardown"))) + +(apply -main *command-line-args*) diff --git a/test/localstack_test.clj b/test/localstack_test.clj new file mode 100644 index 0000000..df0c447 --- /dev/null +++ b/test/localstack_test.clj @@ -0,0 +1,75 @@ +(ns localstack-test + (:require [clojure.test :refer [deftest is run-tests]])) + +(load-file "script/localstack.clj") + +;; LAMBDA_ENDPOINT_URL is the single switch: unset/blank means real AWS +;; (existing behavior, untouched); set means every aws CLI call in this repo +;; goes to that endpoint instead, with LocalStack dummy credentials. + +(deftest endpoint-reads-lambda-endpoint-url-from-env + (is (= "http://localhost:4566" + (script.localstack/endpoint {"LAMBDA_ENDPOINT_URL" "http://localhost:4566"}))) + (is (= "http://localhost:4566" + (script.localstack/endpoint {"LAMBDA_ENDPOINT_URL" " http://localhost:4566 "}))) + (is (nil? (script.localstack/endpoint {}))) + (is (nil? (script.localstack/endpoint {"LAMBDA_ENDPOINT_URL" ""}))) + (is (nil? (script.localstack/endpoint {"LAMBDA_ENDPOINT_URL" " "})))) + +(deftest with-endpoint-inserts-global-option-right-after-aws + (is (= ["aws" "--endpoint-url" "http://localhost:4566" "lambda" "invoke"] + (script.localstack/with-endpoint ["aws" "lambda" "invoke"] "http://localhost:4566"))) + (is (= ["aws" "sts" "get-caller-identity"] + (script.localstack/with-endpoint ["aws" "sts" "get-caller-identity"] nil)))) + +(deftest with-endpoint-passes-non-aws-argv-through-unchanged + ;; demo.clj's sh builds both aws and docker commands through one helper + (is (= ["docker" "info"] + (script.localstack/with-endpoint ["docker" "info"] "http://localhost:4566")))) + +(deftest credentials-env-gives-localstack-dummies-only-when-endpoint-set + (is (= {"AWS_ACCESS_KEY_ID" "test" + "AWS_SECRET_ACCESS_KEY" "test" + "AWS_DEFAULT_REGION" "us-east-1"} + (script.localstack/credentials-env {} "http://localhost:4566"))) + ;; {} rather than nil: the sh helpers pass this straight to + ;; babashka's :extra-env, which wants a map either way + (is (= {} (script.localstack/credentials-env {} nil))) + (is (= {} (script.localstack/credentials-env {} "")))) + +(deftest credentials-env-lets-caller-set-values-win + (is (= {"AWS_ACCESS_KEY_ID" "keep-me" + "AWS_SECRET_ACCESS_KEY" "test" + "AWS_DEFAULT_REGION" "eu-west-1"} + (script.localstack/credentials-env {"AWS_ACCESS_KEY_ID" "keep-me" + "AWS_DEFAULT_REGION" "eu-west-1"} + "http://localhost:4566")))) + +(deftest docker-run-argv-runs-detached-name-exposes-edge-and-docker-socket + (let [argv (script.localstack/docker-run-argv {})] + (is (= "docker" (first argv))) + (is (= ["--name" script.localstack/container-name] + (->> argv + (partition 2 1) + (some (fn [[k v]] (when (= k "--name") [k v])))))) + (is (some #(= "-p" %) argv)) + (is (some #{"4566:4566"} argv)) + ;; the Lambda executor needs the host docker daemon to run function containers + (is (some #(= "/var/run/docker.sock:/var/run/docker.sock" %) argv)) + (is (= script.localstack/image (last argv))))) + +(deftest docker-run-argv-honors-image-override-and-auth-token + ;; LocalStack >= 2026.3 requires LOCALSTACK_AUTH_TOKEN even for community + ;; features; the default image is pinned to the last tokenless tag. + (let [argv (script.localstack/docker-run-argv + {"LOCALSTACK_IMAGE" "localstack/localstack:latest" + "LOCALSTACK_AUTH_TOKEN" "ls-abc"})] + (is (= "localstack/localstack:latest" (last argv))) + (is (= ["-e" "LOCALSTACK_AUTH_TOKEN=ls-abc"] + (->> argv (partition 2 1) (some (fn [[k v]] (when (= k "-e") [k v]))))))) + (is (nil? (->> (script.localstack/docker-run-argv {}) + (partition 2 1) + (some #(= (first %) "-e")))))) + +(let [{:keys [fail error]} (run-tests 'localstack-test)] + (System/exit (if (zero? (+ fail error)) 0 1)))