Compare commits

...

55 Commits

Author SHA1 Message Date
Zakaria c9dbdbe807 Merge pull request 'feat(efs): one shared store per file system, served to EC2 over NFS' (#17) from fix/efs-shared-file-system into main
Reviewed-on: #17
2026-10-26 02:47:19 +00:00
Zakaria bc3d16bfb6 feat(efs): one shared store per file system, served to EC2 over NFS
An EFS file system is now one Docker volume, floci-efs-<file system id>,
that every mount of it shares, as on AWS:

- An access point or ECS rootDirectory mounts its folder of that volume
  (Docker volume subpath), so a mount of the file system root sees every
  access point's files, and Lambda and ECS see each other's. Before, each
  access point (Lambda) or root directory (ECS) got its own volume.
- An access point's root directory is created on first use from its
  CreationInfo, every missing directory on the path with its owner and
  permissions; without CreationInfo a missing root directory is refused.
- A Lambda function runs as the access point's POSIX user and secondary
  groups. ECS tasks keep their own user, as on AWS.
- CreateAccessPoint validates RootDirectory.Path, CreationInfo and
  PosixUser the way EFS does.
- DeleteFileSystem removes the file system's volume.

Each mount target is now a real EC2 network interface in its subnet, so
its address comes from the subnet's lease table and no instance is handed
it, and a privileged helper container serves the volume over NFSv4.1 at
that address on the VPC's Docker network (kernel nfsd, image built
locally from docker/efs-nfs-server.Dockerfile). A mount target is
creating until its server answers. The embedded DNS server resolves
[zone.]fs-id.efs.region.amazonaws.com to a mount target, so an EC2
instance mounts the file system with AWS's own commands (NFS client or
amazon-efs-utils without tls). Servers come back after a restart, their
addresses re-reserved first. floci.services.efs.nfs-servers turns it off.

Two EC2 fixes this needed:
- DeleteNetworkInterface returns the interface's addresses to the subnet;
  they stayed leased until restart, also for ECS awsvpc tasks.
- A VPC whose CIDR is substituted keeps each subnet at its declared
  offset inside the substitute range, instead of the next free slice in
  declaration order, so the plan and persisted addresses survive a
  restart that loads subnets in another order.
2026-10-25 21:44:24 -04:00
Zakaria 7617e1f87c Merge pull request 'fix(athena): run and list queries in their workgroup' (#16) from fix/athena-workgroup-queries into main
Reviewed-on: #16
2026-10-25 23:44:18 +00:00
Zakaria 299a7704c2 Merge pull request 'fix(glue): leave unset fields out of catalog responses' (#15) from fix/glue-omit-null-fields into main
Reviewed-on: #15
2026-10-25 23:44:08 +00:00
Zakaria a52602186d Merge pull request 'fix(efs): place mount targets in their real subnet' (#14) from fix/efs-mount-target-subnet into main
Reviewed-on: #14
2026-10-25 23:43:51 +00:00
Zakaria 57aa866144 fix(athena): report a failed query's error the way Athena does
StateChangeReason carried the raw engine call failure, HTTP envelope and all:
floci-duck execute returned HTTP 500: {"status":"error","message":...},
ending with the statement as the engine saw it, wrapped in COPY (...) TO. It is
now the engine's own message alone, such as Catalog Error: Table with name nope
does not exist!
2026-10-25 18:07:31 -04:00
Zakaria ac96c6451e fix(athena): accept a query that ends in a semicolon
Athena runs a statement with or without its closing semicolon, but the query
engine wraps it in COPY (...) TO ..., where the semicolon is a syntax error, so
SELECT * FROM orders; always failed. Trailing semicolons are now dropped before
the statement is handed to the engine.
2026-10-25 18:04:11 -04:00
Zakaria 39664957fe fix(athena): run and list queries in their workgroup
StartQueryExecution ignored the workgroup: results always went to the default
bucket, even when the workgroup has a result location (or enforces it), and an
unknown workgroup was accepted. ListQueryExecutions ignored the WorkGroup
filter and returned every query.

Now, as in AWS, a query without a result location uses its workgroup's, an
enforcing workgroup's location always wins, an unknown workgroup is an
InvalidRequestException, and ListQueryExecutions returns the named workgroup's
queries (the primary workgroup's when none is named), newest first.
2026-10-25 17:54:43 -04:00
Zakaria 1b00488b7c fix(glue): write timestamps the AWS SDK for JavaScript can read
Glue times went out as nanosecond epoch numbers (1792964720.708701378). The
AWS SDK for JavaScript v3 keeps any number whose text is not its shortest form
as a BigDecimal and then refuses it as a timestamp, so GetTables still failed.
They are now written as AWS writes them: epoch seconds to the millisecond, in
the shortest form. CreateDatabase also records the database's CreateTime.
2026-10-25 17:48:12 -04:00
Zakaria dfbfa4d2a7 fix(glue): leave unset fields out of catalog responses
Database, Table, Partition, UserDefinedFunction, Job, Crawler and LastCrawlInfo
were serialized with every unset field as null. AWS omits them, and the AWS SDK
for JavaScript v3 rejects a null timestamp outright, so GetTables failed for
any table that had never been updated (UpdateTime and LastAccessTime: null).
2026-10-25 17:41:57 -04:00
Zakaria 5dd9a1e346 fix(efs): place mount targets in their real subnet
CreateMountTarget now looks the subnet up in EC2 and takes the mount target's
VPC, Availability Zone and IP address from it, instead of reporting
vpc-12345678 and the region's first zone for every mount target. An unknown
subnet is refused with SubnetNotFound, and, as in AWS, a file system gets one
mount target per zone, all in one VPC (MountTargetConflict otherwise).

Lambda checks a function's VPC against its file system's mount targets, so the
made-up VPC kept consoles from finding the security group to use.
2026-10-25 17:25:15 -04:00
Zakaria 00b3af3813 Merge pull request 'fix(acm): keep the Export option on RequestCertificate' (#12) from fix/acm-request-export-option into main
Reviewed-on: #12
2026-10-25 17:21:08 +00:00
Zakaria d8d7dd021b fix(acm): keep the Export option on RequestCertificate
RequestCertificate always stored Export as DISABLED, so an exportable public
certificate could never be exported. Read Export and the transparency logging
preference the way UpdateCertificateOptions does, rejecting values other than
ENABLED and DISABLED.
2026-10-25 12:30:11 -04:00
Zakaria eaf5ca6ade Merge pull request 'docs: rebrand the README as TheCloud's AWS runtime' (#11) from docs/readme-thecloud into main
Reviewed-on: #11
2026-10-25 14:29:43 +00:00
Zakaria cc64729599 docs: rebrand the README as TheCloud's AWS runtime
Use TheCloud's logo and name, describe the repository as the fork of Floci
behind TheCloud with its own changes, and replace instructions for upstream's
published artifacts (Floci CLI, Docker Hub tags and release train) with
building and running this repository. Drop the upstream badges, comparison
table, community, sponsor, star-history and contributor sections, keep literal
identifiers (FLOCI_* settings, upstream package names), and credit Floci under
License.
2026-10-25 10:11:34 -04:00
Zakaria 9f17f72cc3 Merge pull request 'fix(apigatewayv2): verify tokens from Floci's own Cognito pools in-process' (#10) from fix/apigatewayv2-local-cognito-issuer into main
Reviewed-on: #10
2026-10-25 13:49:41 +00:00
Zakaria d8d94a773d fix(apigatewayv2): verify tokens from Floci's own Cognito pools in-process
A JWT authorizer on one of Floci's own user pools could never pass: the pool's
issuer is Floci's base URL (plain HTTP on localhost or a Compose service name),
and the verifier only fetches discovery and JWKS documents over HTTPS or from a
private IP literal. When the configured issuer is exactly one of this
emulator's pool issuers, read the pool's signing keys directly; they are the
keys it publishes at its JWKS URL. Every other issuer keeps the strict fetch.
2026-10-25 08:57:05 -04:00
Zakaria 957579c8b7 Merge pull request 'fix(cognito): honour PreventUserExistenceErrors=ENABLED on app clients' (#9) from fix/cognito-prevent-user-existence-errors into main
Reviewed-on: #9
2026-10-25 12:27:48 +00:00
Zakaria d9a1a36dc8 fix(cognito): honour PreventUserExistenceErrors=ENABLED on app clients
The setting was stored but never applied, so an unknown username always got
UserNotFoundException and revealed which accounts exist. With ENABLED, answer
as real Cognito does: password sign-in fails with NotAuthorizedException
"Incorrect username or password", ForgotPassword and ResendConfirmationCode
report a simulated delivery, and ConfirmForgotPassword and ConfirmSignUp fail
with CodeMismatchException. LEGACY and unset keep UserNotFoundException.
SRP and USER_AUTH flows are unchanged.
2026-10-25 08:26:34 -04:00
Zakaria 5d4b010710 Merge pull request 'fix(ses): sign in to the SMTP relay with the password, not as an OAuth token' (#8) from fix/ses-smtp-password-auth into main
Reviewed-on: #8
2026-10-25 11:57:20 +00:00
Zakaria 2486869b6b fix(ses): sign in to the SMTP relay with the password, not as an OAuth token
Vert.x mail prefers XOAUTH2 whenever the server offers it, so against Gmail the
app password was sent as an OAuth token and refused (535 5.7.8). Limit the
relay to the password-based SASL methods.
2026-10-25 07:52:52 -04:00
Zakaria 327173c8f6 Merge pull request 'chore(docker): read optional SES SMTP relay settings from ~/.config/thecloud/ses-smtp.env' (#7) from feat/ses-smtp-relay-env into main
Reviewed-on: #7
2026-10-25 11:46:41 +00:00
Zakaria dcb007f8b8 chore(docker): read optional SES SMTP relay settings from ~/.config/thecloud/ses-smtp.env 2026-10-25 07:44:42 -04:00
Zakaria 607ac0803b Merge pull request 'fix(cloudwatch): retain metric-math alarm query definitions (#4594)' (#6) from feat/cloudwatch-upstream-fixes into main
Reviewed-on: #6
2026-10-25 03:08:55 +00:00
Zakaria a9df81a294 chore: add the upstream helpers the composite alarm change builds on
Composite alarms from upstream (#6408) use SmithyValidationErrors for AWS's
validation messages, and their tests use the CfnStackWaits, PartitionCleanup
and ThreadStates test helpers and ReplacementCleanup.owedPhysicalIds. These
are copied from upstream as they were at that commit; none brings in other
behaviour.
2026-10-24 22:23:29 -04:00
Andre Kurait fa3327bf71 feat(cloudwatch): support composite alarm lifecycle (#6408)
(cherry picked from commit 922e36a472341861e97d1b5639656f3c4a3978d8)
2026-10-24 22:23:29 -04:00
Stuart Lang 83aafc0325 fix(cloudwatch): return empty CompositeAlarms and LogAlarms from DescribeAlarms (#6363)
* fix(cloudwatch): return empty CompositeAlarms and LogAlarms from DescribeAlarms

AWS sends every alarm collection even when it is empty. Floci only sent
MetricAlarms, so the AWS CLI's text output printed a leading None line
for each missing paginator result key.

Closes #6362

* test(cloudwatch): check empty alarm collections in sdk-test-python instead of bats

Drop the new AWS CLI bats file and assert the empty CompositeAlarms and
LogAlarms lists in the existing describe_alarms test, as boto3 leaves an
absent list out of the response.

(cherry picked from commit 42f5019231b446c727d6c94def9d1445263de2d7)
2026-10-24 22:19:09 -04:00
Mohith 24314f4432 fix(cloudwatch): reject PutMetricData datums without a MetricName (#6072)
* fix(cloudwatch): reject PutMetricData datums without a MetricName

MetricName is the one required member of MetricDatum. The JSON handler
stored a nameless datum under an empty name, and the Query handler
treated a member without MetricName as the end of the list, silently
dropping it and every later datum while still storing the earlier ones.

Both handlers now reject a missing or empty MetricName in top-level
MetricData and in EntityMetricData[].MetricData with MissingParameter
(400), the error PutMetricData declares for a missing required input,
naming the offending member. A whitespace name is still accepted, since
AWS constrains MetricName only to 1 to 255 characters. Every datum is
validated before any is stored, so a rejected request leaves no state
behind. On Query, a member index with no keys at all still ends that
MetricData list.

* fix(cloudwatch): return MissingRequiredParameterException on JSON and CBOR

CloudWatch is awsQueryCompatible. MissingParameter is the awsQueryError
code of the MissingRequiredParameterException shape, so it belongs in the
Query <Code> and the x-amzn-query-error header, while a JSON 1.0 or CBOR
body's __type names the shape. With the code in __type the AWS SDK v2
raised a generic CloudWatchException instead of the modelled one.

A package-private CloudWatchMetricsException keeps the Query code as its
error code and overrides jsonType() with the shape name, the same split
SwfException makes. The shared query-code table in AwsException is left
alone because SQS reports MissingParameter as its JSON type.

(cherry picked from commit b8d87df05413ea8c4f9497fa05f388c19d4124ec)
2026-10-24 22:19:09 -04:00
Zakaria e90978ae94 fix(cloudwatch): name the alarm's region without the partition catalog
The SNS alarm notification from upstream (#5918) looks up the region's display
name in AwsPartitions, which upstream added in a partition rework not taken
here. A table of the commercial regions' names gives the same Region field.
2026-10-24 22:19:09 -04:00
Mohith 21625c5a1b fix(cloudwatch): store datums sent in PutMetricData EntityMetricData (#6070)
The CloudWatch Agent sends PutMetricData with an empty top-level
MetricData and every datum nested under EntityMetricData[].MetricData.
Both the JSON 1.0 handler (also reached by Smithy RPC v2 CBOR) and the
Query handler read only the top-level list, so the agent's metrics were
silently dropped.

Both handlers now append each EntityMetricData entry's datums to the
top-level ones and store them under the request Namespace, with the same
parsing as top-level datums. Entity and StrictEntityValidation are
accepted and ignored. The per-request INFO logs of the raw request and
parsed datums are lowered to a DEBUG count.

Fixes #6066

(cherry picked from commit 03f57303969561b049c3ebba38f1fd9d2b68df4c)
2026-10-24 22:19:09 -04:00
mrjusepelo 2e0a438d70 fix(cloudwatch): publish SNS actions on alarm state transitions (#5918)
(cherry picked from commit f5811c3a0735be634b6122503237330826e0ba4a)
2026-10-24 22:17:17 -04:00
Allen Sanborn ccdf32335d fix(cloudwatch): raise ResourceNotFoundException when a tag operation names a resource that does not exist (#4783)
* fix(cloudwatch): raise ResourceNotFoundException when a tag operation names a resource that does not exist

TagResource, UntagResource and ListTagsForResource answered 200 for any ARN, so
tagging a typo'd or deleted alarm, dashboard or metric stream told the caller the
tag had been applied. AWS raises ResourceNotFoundException. All three operations
now resolve the ARN first and report a 404 when nothing answers to it.

The fix sits in the three services rather than in the handlers, because CloudWatch
Metrics serves these operations over both Query and JSON 1.1 and the two handlers
route to the same service methods. A handler-level check would have left the other
protocol still answering 200.

What the 200 was hiding is worth naming, because "return an error instead of 200"
understates it. The write path resolved the ARN and then did nothing with the miss:
tagResource and untagResource each ended in an ifPresent, so a tag against an
unknown ARN was discarded, and ListTagsForResource answered the empty map. So the
ARN was neither rejected nor recorded. Nothing was stored against a phantom
resource, which is the one mercy here, but a caller that wrote a tag and read back
an empty list had no way to tell "this resource has no tags" from "this resource
does not exist".

That is why ListTagsForResource is included rather than left alone. It is the one
operation whose answer changes for a caller doing a speculative list, and the one
whose leniency made the silent write survive a round trip. No test or caller in the
repository depended on the empty-map answer for an unknown ARN: the only internal
caller is CloudWatchDashboardCfnProvisioner.reconcileTags, and both of its call
sites pass an ARN that putDashboard has just returned.

The error code is ResourceNotFoundException, not the shorter ResourceNotFound that
GetDashboard, DeleteDashboards and SetAlarmState answer with. Those are two separate
shapes in the CloudWatch model, mapped to two exception classes by the SDK, and the
model declares the longer one on exactly these three operations.

CloudWatch Logs needed no change. Its own TagResource, UntagResource and
ListTagsForResource already resolve the log group and raise
ResourceNotFoundException, which the new test pins so the two halves of CloudWatch
tagging cannot diverge in the other direction.

* fix(cloudwatch): resolve a dashboard by either the regionless or the region-ful form of its ARN

Dashboards are a global resource and AWS documents their ARN without a region,
arn:aws:cloudwatch::<account-id>:dashboard/<dashboard-name>, on both the TagResource
and the ListTagsForResource pages. Floci mints a region-ful one. The dashboard
lookup now accepts both shapes deliberately, so a caller holding either resolves.

Accepting both is what stops the preceding commit regressing a caller that does the
documented thing. Until the tag operations raised on a miss, the divergence was
invisible on this path: the documented ARN matched no stored dashboard, and the
answer was a silent 200 with an empty tag map. Turning a miss into
ResourceNotFoundException would have turned that into a hard 404 for a dashboard
that exists.

Only an absent region stands in for the dashboard's own, so an ARN naming some other
region is still a miss and does not resolve to a same-named dashboard here.

What Floci mints is not changed. Clients may already hold the ARNs it has handed
out, so bringing the minted shape into line with AWS alters values already in
circulation and belongs in its own change; it is tracked separately. The tolerant
lookup is all that is needed to keep this path correct, and it is strictly more
permissive than before, so it cannot break a caller that works today.

Alarms and metric streams were checked rather than assumed: both mint region-ful
ARNs and AWS documents both region-ful, so the question is dashboards only.

Also reworded the not-found message on the alarm path to name the ARN that did not
resolve instead of asserting it was an alarm. Both tag handlers route every ARN that
is not a dashboard and not a metric stream to that path, including kinds AWS
considers taggable and Floci does not serve, a Contributor Insights insight-rule ARN
being the one AWS documents. Reporting that no resource matches the ARN is true of
those; "alarm not found" was not. No Contributor Insights support is added here.

* test(cloudwatch): assert a refused tag call left the real resource untagged

The not-found tests tagged the unknown ARN env=prod and then re-tagged the real
resource env=dev before reading it back, so a failed call that had written through
to the real resource would have been overwritten and the assertion would still
pass. Read the real resource's tags first; they must be empty.

* test(cloudwatch): import matchers and use configureAwsContentTypes

Import Matchers.not and java.util.Map in the new tests instead of writing
them inline, register the x-amz-json content types through
RestAssuredJsonUtils.configureAwsContentTypes() in place of registerParser
and the hand-built encoder config, and drop DeleteDashboards from the
requireDashboard Javadoc: only GetDashboard declares ResourceNotFound.

(cherry picked from commit 0c8d1848541392ff91991121d96586db564cb3f8)
2026-10-24 22:17:17 -04:00
Aniket Shukla cbb95ba26c fix(cloudwatch): retain metric-math alarm query definitions (#4594)
* fix(cloudwatch): retain metric-math alarm query definitions

* test(cloudwatch): use shared XML parser for alarm assertions

(cherry picked from commit 1d646c6fea70a8d4bfeea4d0ca6d8fc390ea6170)
2026-10-24 22:17:17 -04:00
Zakaria 90d5dc9427 Merge pull request 'fix(cognito): keep revoked tokens across restarts' (#5) from fix/cognito-revoked-tokens-reload into main
Reviewed-on: #5
2026-10-24 18:52:58 +00:00
Zakaria a59913bb38 fix(cognito): keep revoked tokens across restarts
RevokedTokenInfo serialized its derived isExpired() as "expired", which the
persistent store's ObjectMapper then refused to read back. On restart the file
was moved aside as .corrupt and every revocation was forgotten, so refresh
tokens revoked by sign-out worked again. The derived field is no longer
written, and files that carry it are read with it ignored.
2026-10-24 14:50:02 -04:00
Zakaria 2969939a99 Merge pull request 'feat(cloudfront): serve POST, PUT, PATCH and DELETE viewer requests (#4229)' (#4) from feat/cloudfront-upstream-fixes into main
Reviewed-on: #4
2026-10-24 18:38:31 +00:00
Zakaria 31196a0367 fix(s3): open an object's metadata and body stream together for CloudFront
The streaming change from upstream (#5278) serves S3 origins through
S3Service.openObject, which upstream added in a larger disk-streaming rework
(#4691) not taken here. This adds the same method on top of the existing
openObjectStream.
2026-10-24 13:58:20 -04:00
Abhigya 55a981069c fix(cloudfront): release origin connections when viewers disconnect (#5564)
(cherry picked from commit ee229af97569d5876f756f65ce7956726e2ed696)
2026-10-24 13:53:54 -04:00
Aymen JAMAAOUI 13e91cbb5a fix(cloudfront): reject a function runtime that CloudFront does not support (#5445)
Closes #5425

Adapted: kept only the runtime check; the duplicate-name check that surrounds it
upstream comes from a later CloudFront Functions change not taken here.

(cherry picked from commit 8deac0a4244449470d6bd4bfa8769f8921d1533d)
2026-10-24 13:53:54 -04:00
Priamo Germosen ec4664589b fix(cloudfront): stream origin responses and viewer request bodies (#5278)
A distribution read every origin response whole before answering the
viewer: an S3 origin's object through getObject, a custom origin's body
through the HTTP client into a byte array. A viewer's POST, PUT, PATCH
or DELETE body was likewise read whole before it was forwarded. So an
object or upload of about 2 GiB or more failed, and a smaller one could
run the heap out of memory.

S3 origins now open the object as a stream, the snapshot GetObject
serves, and custom origins return their response before its body is
read; either body is written to the viewer as it arrives, with its
Content-Length, or chunked as the origin sent it. A viewer's body is
streamed to the custom origin with its Content-Length, or chunked when
the viewer sent it chunked or, as HTTP/2 allows, with no length.

An origin response closed before its end, as when the viewer goes away
or a custom error page replaces it, cancels its request instead of
reading the rest of the body to reuse the connection. Since a streamed
response holds its connection for the whole transfer, one origin may
now use the whole connection pool, as CloudFront sets no per-origin
limit.

(cherry picked from commit 1f1d4f94dd8a179972bda124bc3aa47b9704f69b)
2026-10-24 13:53:10 -04:00
jwoa95 40ee0e7822 feat(cloudfront): forward viewer headers, cookies and query strings per origin request policy (#4230)
Custom origins only ever received the origin custom headers: the serving
controller built each origin request from them alone and dropped the
query string, whatever cache policy, origin request policy or legacy
ForwardedValues the matched cache behavior carried.

CloudFrontOriginRequestBuilder now decides what reaches the origin. With
a cache policy it forwards the union of the cache policy's
ParametersInCacheKeyAndForwardedToOrigin and the origin request policy
(every HeaderBehavior, CookieBehavior and QueryStringBehavior value).
Without one it applies legacy ForwardedValues, including the headers
CloudFront forwards by default. It also adds what CloudFront always
sends: Host for the origin unless the viewer Host is forwarded,
User-Agent "Amazon CloudFront", X-Forwarded-For, Via and X-Amz-Cf-Id.
Authorization is kept on the methods CloudFront does not cache, and
CloudFront-Forwarded-Proto, -Viewer-Address and -Viewer-Http-Version are
generated when a policy asks for them. Origin custom headers now replace
every same-named viewer header, and signing parameters are still never
forwarded.

The eight AWS managed origin request policies are served by their AWS
IDs and cannot be updated or deleted. With no origin request policy and
nothing selected by the cache policy, no cookies or query strings are
forwarded, as before.

Closes #4217

(cherry picked from commit f8f1b56ce9e9a1cbd63642ec1c4144fe8f6dd661)
2026-10-24 13:53:10 -04:00
jwoa95 aef858a47c feat(cloudfront): serve POST, PUT, PATCH and DELETE viewer requests (#4229)
The serving controller only declared GET, HEAD and OPTIONS, so a write
method on a distribution host matched the resource class but no method
and fell through to a bare 405, even when the cache behavior allowed all
seven methods.

POST, PUT, PATCH and DELETE are now served under the same AllowedMethods
check (403 "Invalid method." otherwise) and the same trusted key group
enforcement as the read methods. Custom origins receive the method, the
viewer request body, its Content-Type and its Authorization header, with
Content-Length taken from the body; GET and HEAD still reach the origin
without Authorization, as on CloudFront. In-process S3 origins answer
these methods with 403 Access Denied instead of forwarding them, which
is documented.

Closes #4216

(cherry picked from commit 4ca0e085cd9064cf7672cb050049e9564b48ed55)
2026-10-24 13:53:10 -04:00
Zakaria e557be1bc6 Merge pull request 'feat(cognito): show the client's identity providers on the hosted login page' (#3) from feat/cognito-hosted-login-providers into main
Reviewed-on: #3
2026-10-24 16:18:07 +00:00
Zakaria 174cb600d4 feat(cognito): show the client's identity providers on the hosted login page
The hosted login page now offers a "Continue with <provider>" button for each
of the app client's other SupportedIdentityProviders, carrying the same
authorize parameters (state, nonce, PKCE) into the federated flow, as Cognito's
managed login does. A client that lists only external providers gets the
buttons without the password form instead of an error.
2026-10-24 12:14:04 -04:00
Zakaria 2e7ebefca9 Merge pull request 'feat(cognito): hosted login page, PKCE and refresh-token grant' (#2) from feat/cognito-hosted-login into main
Reviewed-on: #2
2026-10-24 15:46:36 +00:00
Zakaria f4bbb6fab4 feat(cognito): hosted login page, PKCE and refresh-token grant
The pool's own users can now sign in through /oauth2/authorize the way apps
expect Cognito to work: without identity_provider (or with COGNITO) the
browser lands on a hosted login page, and a correct password redirects to the
registered callback with a one-time code.

- GET/POST /cognito-idp/login (and /login on custom domains), with a CSRF
  cookie and the same password rules and Pre/Post Authentication triggers as
  USER_PASSWORD_AUTH; wrong passwords, unknown and unconfirmed users get the
  same answer until the password is right
- a one-hour browser session, so the next authorize skips the form unless it
  sends prompt=login, and /logout with LogoutURLs validation
- PKCE S256 on both hosted and federated codes, and nonce in the ID token
- refresh_token grant on /oauth2/token
- codes now fire Pre Token Generation with TokenGeneration_HostedAuth instead
  of skipping the trigger
2026-10-24 11:09:37 -04:00
Zakaria e7fb8bb8d6 Merge pull request 'fix(docker): use Floci's address on the configured docker network' (#1) from fix/prefer-configured-docker-network into main
Reviewed-on: #1
2026-10-24 05:43:01 +00:00
Zakaria a1510d8cd6 fix(docker): use Floci's address on the configured docker network
Spawned containers (Lambda, RDS, EKS, EC2, ...) join services.docker-network,
but the address they were given to reach Floci was its IP on the first
user-defined network it is attached to. Once Floci is also attached to an
emulated VPC network, that address is unreachable and every Lambda times out
with "Failed to get next invocation". Prefer the configured network when
Floci is attached to it.
2026-10-24 01:35:31 -04:00
Zakaria bbe7e19554 small fixes 2026-10-15 10:25:56 -04:00
Zakaria 33c480e7e1 feat: Lightsail + svg animation + ec2 bug fixes 2026-10-11 14:19:49 -04:00
Zakaria 019570d8a4 bug fixes: backend services patches - Implemented and deployed systemd-backed AMIs 2026-10-07 13:22:32 -04:00
Zakaria 7135c81050 feat: Fargate, ECS, ALB,Codebuild, CodePipeline 2026-10-02 09:54:55 -04:00
Zakaria 283fcc7ab8 RDS port issue fixed 2026-09-30 18:38:54 -04:00
Zakaria 23d24288cd backend services patching 2026-09-30 17:46:10 -04:00
Zakaria cede01969e feat: paginate step functions executions 2026-09-27 23:35:21 -04:00
269 changed files with 20260 additions and 1747 deletions
+80 -267
View File
@@ -1,21 +1,17 @@
<p align="center">
<img src="docs/assets/floci-black.svg#gh-light-mode-only" alt="Floci" width="500" />
<img src="docs/assets/floci-white.svg#gh-dark-mode-only" alt="Floci" width="500" />
<img src="docs/assets/thecloud-logo.png" alt="TheCloud" width="160" />
</p>
<h1 align="center">TheCloud AWS runtime</h1>
<p align="center">
<strong>Any Cloud. Locally.</strong><br />
Light, fluffy, and always free<br />
The local AWS emulator behind TheCloud.<br />
No account. No auth token. No feature gates. Just <code>docker compose up</code>.
</p>
<p align="center">
<a href="https://github.com/floci-io/floci/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci?label=latest%20release&color=blue" alt="Latest Release"></a>
<a href="https://github.com/floci-io/floci/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci/release.yml?label=build" alt="Build Status"></a>
<a href="https://hub.docker.com/r/floci/floci"><img src="https://img.shields.io/docker/pulls/floci/floci?label=docker%20pulls" alt="Docker Pulls"></a>
<a href="https://hub.docker.com/r/floci/floci"><img src="https://img.shields.io/docker/image-size/floci/floci/latest?label=image%20size" alt="Docker Image Size"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
<a href="https://github.com/floci-io/floci/stargazers"><img src="https://img.shields.io/github/stars/floci-io/floci?style=flat" alt="GitHub Stars"></a>
</p>
<p align="center">
@@ -24,41 +20,43 @@
<a href="#supported-services">Services</a> ·
<a href="#sdk-integration">SDKs</a> ·
<a href="#testcontainers">Testcontainers</a> ·
<a href="#migrating-from-localstack">Migration</a> ·
<a href="https://floci.io/floci/">Docs</a>
<a href="#migrating-from-localstack">Migration</a>
</p>
---
## What is Floci?
## What is this?
Floci is a free, open-source local AWS emulator for development, testing, and CI.
TheCloud's AWS runtime is a local AWS emulator for development, testing, and CI. It is the runtime behind [TheCloud](https://gitea.zacksolutions.dev/Zakaria/floci-ui), a self-hosted cloud console.
It gives you AWS-shaped services on your machine without requiring a cloud account, an auth token, or paid feature gates. Point your AWS SDK, CLI, Terraform, CDK, OpenTofu, or test suite at `http://localhost:4566` and keep your existing workflows.
It gives you AWS-shaped services on your machine without a cloud account, an auth token, or paid feature gates. Point your AWS SDK, CLI, Terraform, CDK, OpenTofu, or test suite at `http://localhost:4566` and keep your existing workflows.
Already using LocalStack? Floci is a drop-in replacement: swap the image and keep going. See [Migrating from LocalStack](#migrating-from-localstack).
It is a fork of the open-source [Floci](https://github.com/floci-io/floci) emulator. Upstream fixes are brought in by cherry-picking from Floci's `main`, alongside TheCloud's own changes, for example:
Floci is the AWS member of the [Floci](https://github.com/floci-io) emulator family, named after [floccus](https://en.wikipedia.org/wiki/Cirrocumulus_floccus), the cloud formation that looks like popcorn.
- Cognito hosted login with identity-provider buttons, and `PreventUserExistenceErrors` honoured on app clients.
- CloudFront distributions served at `<id>.cloudfront.localhost`.
- SES delivering real email through an SMTP relay that signs in with a password (Gmail app passwords work).
- HTTP API JWT authorizers that verify tokens from this runtime's own Cognito pools.
| Emulator | Cloud | Port |
|---|---|:---:|
| **[floci](https://github.com/floci-io/floci)** | **AWS** | **4566** |
| [floci-az](https://github.com/floci-io/floci-az) | Azure | 4577 |
| [floci-gcp](https://github.com/floci-io/floci-gcp) | GCP | 4588 |
| [floci-oci](https://github.com/floci-io/floci-oci) | OCI | 4599 |
Companion runtimes for other clouds come from upstream: [floci-az](https://github.com/floci-io/floci-az) for Azure on port 4577 and [floci-gcp](https://github.com/floci-io/floci-gcp) for GCP on port 4588.
## Quick Start
The fastest way to run Floci is with the official [CLI](https://github.com/floci-io/floci-cli)
Build and start the runtime from this repository:
```bash
floci start
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git
cd FlociClone
docker compose up -d
```
Export the AWS environment variables:
```bash
eval $(floci env)
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
```
Use your existing AWS tools normally:
@@ -75,121 +73,41 @@ aws dynamodb create-table \
aws dynamodb list-tables
```
### Watch it run
This short demo shows the CLI flow: start Floci, export the local AWS environment, run standard AWS CLI commands, and stop the emulator.
https://github.com/user-attachments/assets/b55714dc-ef36-40ae-a734-cd2cadc288a8
All AWS services are available at `http://localhost:4566`. Any region works. Credentials can be any non-empty values unless you explicitly enable stricter service-specific auth checks.
<details>
<summary>Prefer Docker Compose?</summary>
<summary>Prefer <code>docker run</code>, or dev mode?</summary>
Create a `compose.yaml` file:
```yaml
services:
floci:
image: floci/floci:latest
ports:
- "4566:4566"
```
Start Floci:
Build the image, then run it with the Docker socket so container-backed services work:
```bash
docker compose up
docker build -f docker/Dockerfile -t floci-floci:latest .
docker run -d --name floci -p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
floci-floci:latest
```
Then configure your AWS environment manually:
Or run it in Quarkus dev mode, which needs JDK 25:
```bash
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
./mvnw quarkus:dev
```
</details>
<details>
<summary>Using the old <code>hectorvent/floci</code> image?</summary>
Update your image name:
```yaml
# Before
image: hectorvent/floci:latest
# After
image: floci/floci:latest
```
The old `hectorvent/floci` repository no longer receives updates.
</details>
## Web Console
Floci ships a browser console for inspecting the resources in your local emulator.
[TheCloud](https://gitea.zacksolutions.dev/Zakaria/floci-ui) is the console for this runtime. It runs as its own app on port 4500, talks to the runtime at `FLOCI_ENDPOINT`, and covers the services listed in its README.
**Open it at:** `http://localhost:4566/_floci/ui`
Nothing runs at boot. The first request pulls the console image, starts it as a sidecar container on Floci's Docker network, hands it Floci's own reachable address plus the standard AWS environment, polls its health endpoint, and redirects the browser once it reports it can reach Floci. The sidecar's port is bound by Docker, so it needs no `ports:` entry of your own, and its logs are streamed into CloudWatch Logs under `/floci/ui`.
Starting a container needs the Docker socket:
```yaml
services:
floci:
image: floci/floci:latest
ports:
- "4566:4566"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
The runtime can also start a console for you as a sidecar container on first visit to `http://localhost:4566/_floci/ui`. That needs the Docker socket, and the image is set with `FLOCI_SERVICES_UI_IMAGE`; point it at your TheCloud build to use it.
| Variable | Default | Description |
|---|---|---|
| `FLOCI_SERVICES_UI_ENABLED` | `true` | Enable the console sidecar |
| `FLOCI_SERVICES_UI_IMAGE` | `floci/floci-ui:latest` | Console image to run |
| `FLOCI_SERVICES_UI_IMAGE` | `floci/floci-ui:latest` | Console image to run, for example `floci/floci-ui:local` for a local TheCloud build |
| `FLOCI_SERVICES_UI_CONTAINER_NAME` | `floci-ui` | Name of the sidecar container |
| `FLOCI_SERVICES_UI_PORT` | `4500` | Host port the console is published on |
| `FLOCI_SERVICES_UI_KEEP_RUNNING_ON_SHUTDOWN` | `false` | Leave the sidecar running when Floci stops |
### Running a different console
The console is not fixed to the one Floci ships. Any console implementing the [Floci console contract](https://floci.io/floci/ui/console-contract/) runs with nothing but an image name: listen on the port in `PORT`, serve `GET /api/health`, talk to Floci at `AWS_ENDPOINT_URL`.
```yaml
environment:
FLOCI_SERVICES_UI_IMAGE: acme/my-console:1.0
```
A console that differs from the contract's defaults says so in its own `io.floci.console.*` image labels, so its operators do not have to. [StackPort](https://github.com/DaviReisVieira/stackport), for example, listens on 8080 rather than 4500; on an image that predates the labels, say it by hand:
```yaml
environment:
FLOCI_SERVICES_UI_IMAGE: davireis/stackport:latest
FLOCI_SERVICES_UI_CONTAINER_NAME: floci-stackport
FLOCI_SERVICES_UI_PORT: "8080"
FLOCI_SERVICES_UI_INTERNAL_PORT: "8080"
```
[Floci Dash](https://github.com/ofsazib/floci-dash) is another. It honours `PORT`, so only the two things it does differ on need naming:
```yaml
environment:
FLOCI_SERVICES_UI_IMAGE: ghcr.io/ofsazib/floci-dash:latest
FLOCI_SERVICES_UI_CONTAINER_NAME: floci-dash
FLOCI_SERVICES_UI_ENDPOINT_ENV: FLOCI_URL
FLOCI_SERVICES_UI_STATUS_PATH: /api/healthz
```
The endpoint itself is never configured by hand: Floci resolves its own reachable address at start time and injects it as `AWS_ENDPOINT_URL`.
Full reference: [Web Console](https://floci.io/floci/ui/) and [Console Contract v1](https://floci.io/floci/ui/console-contract/).
| `FLOCI_SERVICES_UI_KEEP_RUNNING_ON_SHUTDOWN` | `false` | Leave the sidecar running when the runtime stops |
## Features
@@ -203,7 +121,7 @@ Run AWS-compatible services locally without an AWS account, auth token, or paid
<details>
<summary><strong>Real Docker where fidelity matters</strong></summary>
Lambda, RDS, Neptune, ElastiCache, MSK, ECS, EC2, EKS, OpenSearch, CodeBuild, and Managed Service for Apache Flink use real Docker-backed execution instead of shallow mocks.
Lambda, RDS, Neptune, ElastiCache, MSK, MSK Connect, ECS, EC2, EKS, OpenSearch, CodeBuild, and Managed Service for Apache Flink use real Docker-backed execution instead of shallow mocks.
</details>
@@ -212,14 +130,7 @@ Lambda, RDS, Neptune, ElastiCache, MSK, ECS, EC2, EKS, OpenSearch, CodeBuild, an
Point standard AWS clients at `http://localhost:4566`. Existing credentials, regions, SDKs, CLI commands, and IaC workflows stay familiar.
Terraform can provision AWS-shaped resources locally through Floci using the standard HashiCorp AWS provider. See the [Terraform with Floci guide](docs/getting-started/terraform.md) for provider configuration, resource examples, and optional emulated S3 state.
</details>
<details>
<summary><strong>Fast enough for CI</strong></summary>
The native image starts in milliseconds and keeps idle memory low, making it practical for local development and test pipelines.
Terraform can provision AWS-shaped resources locally through this runtime using the standard HashiCorp AWS provider. See the [Terraform guide](docs/getting-started/terraform.md) for provider configuration, resource examples, and optional emulated S3 state.
</details>
@@ -230,36 +141,13 @@ Choose from in-memory, persistent, hybrid, and write-ahead log storage depending
</details>
## Why Floci?
LocalStack's community edition [sunset in March 2026](https://blog.localstack.cloud/the-road-ahead-for-localstack/), requiring auth tokens and freezing security updates. Floci is the no-strings-attached alternative.
| Capability | Floci | LocalStack Community |
|---|:---:|:---:|
| Auth token required | No | Yes |
| Security updates | Yes | Frozen |
| Startup time | ~24 ms | ~3.3 s |
| Idle memory | ~13 MiB | ~143 MiB |
| Docker image size | ~90 MB | ~1.0 GB |
| License | MIT | Restricted |
| API Gateway v2 / HTTP API | Yes | No |
| Cognito | Yes | No |
| RDS, ElastiCache, MSK | Real Docker | No |
| Neptune (graph DB + Gremlin WebSocket) | Real Docker | No |
| DocumentDB (MongoDB-compatible) | Real Docker | No |
| ECS, EC2, EKS | Real Docker | No |
| CodeBuild | Real Docker execution | No |
| Native binary | ~40 MB | No |
**Broad AWS coverage. Free forever.** See the [Services Overview](https://floci.io/floci/services/) for the full list of emulated services.
## Architecture Overview
```mermaid
flowchart LR
Client["AWS SDK / CLI"]
subgraph Floci ["Floci, port 4566"]
subgraph Runtime ["TheCloud AWS runtime, port 4566"]
Router["HTTP Router\nJAX-RS / Vert.x"]
subgraph Stateless ["Stateless Services"]
@@ -288,7 +176,7 @@ flowchart LR
## Supported Services
Floci supports local emulation for application services, data services, eventing, identity, infrastructure, billing, and container-backed workloads.
The runtime emulates application services, data services, eventing, identity, infrastructure, billing, and container-backed workloads.
| Category | Services |
|---|---|
@@ -298,12 +186,12 @@ Floci supports local emulation for application services, data services, eventing
| Containers and compute | ECS, EC2, Lightsail, EKS, MWAA, ECR, EFS, CodeBuild, CodeDeploy, CodePipeline, CodeGuru Reviewer, CodeArtifact, AWS Batch, Auto Scaling, Application Auto Scaling, Elastic Beanstalk, ELB v2, ELB Classic |
| Data, analytics, and AI | Athena, Glue, Lake Formation, EMR, EMR Serverless, Redshift, Redshift Data API, Firehose, Managed Service for Apache Flink, OpenSearch, S3 Tables, S3 Vectors, Textract, Transcribe, Comprehend, Rekognition, Translate, Bedrock, Bedrock Runtime, Bedrock AgentCore, Bedrock AgentCore Control, SageMaker |
| Databases and caching | RDS, RDS Data API, Neptune, DocumentDB, DMS, MemoryDB, ElastiCache, Timestream for InfluxDB |
| Messaging and transfer | SES, Kinesis, MSK, Amazon MQ, Transfer Family, DataSync, IoT Core, Amazon Connect, Amazon AppIntegrations |
| Messaging and transfer | SES, Kinesis, MSK, MSK Connect, Amazon MQ, Transfer Family, DataSync, IoT Core, Amazon Connect, Amazon AppIntegrations |
| Security and governance | AWS Network Firewall, AWS RAM, Service Quotas, WAF v2, GuardDuty, Amazon Inspector, CloudTrail, CloudFront, Resource Groups Tagging API, Resource Explorer 2, CloudHSM v2, Organizations, AWS Account Management, IAM Access Analyzer, IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM), Identity Store, Amazon Macie, Amazon Detective, Security Hub, Amazon Verified Permissions, Control Catalog, Control Tower, Service Catalog, AWS Marketplace |
| Cost and billing | AWS Budgets, Pricing, Cost Explorer, Cost and Usage Reports, BCM Pricing Calculator, BCM Data Exports |
| Resilience, backup, and config | AWS FIS, AWS Backup, AWS Config, AppConfig, AppConfigData, CloudFormation, Cloud Control API |
For operation-level compatibility, see the [Services Overview](https://floci.io/floci/services/).
For operation-level detail, see upstream Floci's [Services Overview](https://floci.io/floci/services/); it can list upstream work this fork has not picked up yet.
<details>
<summary>Detailed service notes</summary>
@@ -342,6 +230,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
| Neptune | Real Docker | Graph DB via TinkerPop Gremlin Server (default) or Neo4j for openCypher/Bolt (`NEPTUNE_DB_TYPE`); RDS-shaped control plane; SigV4 proxy on port 8182 |
| DocumentDB | Real Docker, mock mode available | MongoDB-compatible cluster via real MongoDB containers; RDS-shaped control plane; MongoDB wire protocol on port 27017 |
| MSK | Real Docker | Kafka-compatible broker via Redpanda |
| MSK Connect | Real Docker | Stock Kafka Connect workers with S3 custom plugin ZIPs |
| Amazon MQ | Real Docker | RabbitMQ broker via rabbitmq:3-management; AMQP + management console |
| Athena | In-process with DuckDB sidecar | Real SQL execution over S3 and Glue-backed views |
| Glue | In-process | Data Catalog, Schema Registry, tables consumed by Athena |
@@ -377,7 +266,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
| AWS Backup | In-process | Vaults, backup plans, selections, simulated job lifecycle, recovery points |
| AWS FIS | In-process | All 26 management APIs for templates, experiments, target accounts, action and target discovery, safety lever, tagging, and pagination; experiment execution is a safe control-plane simulation and does not inject faults into other services |
| AWS Config | In-process | Config rules, evaluation-driven compliance (PutEvaluations, compliance details and summaries), configuration recorders, delivery channels, retention configuration, conformance packs, tagging |
| Organizations | In-process | Organizations, roots, nested OUs, member accounts, all policy types with `FullAWSAccess` and effective-policy inheritance, trusted service access, delegated administrators, resource policy, and the full invitation/handshake flow; created accounts are usable Floci accounts and member accounts can read the organization they belong to |
| Organizations | In-process | Organizations, roots, nested OUs, member accounts, all policy types with `FullAWSAccess` and effective-policy inheritance, trusted service access, delegated administrators, resource policy, and the full invitation/handshake flow; created accounts are usable accounts in the runtime and member accounts can read the organization they belong to |
| CloudTrail | In-process | Trails, event selectors (S3 data events with bucket/prefix matching), `StartLogging`/`StopLogging`, scheduled gzipped log file emission to the destination bucket at AWS-shaped key paths, IAM-deny path emits `AccessDenied` records |
| CloudFront | In-process | Distributions, origins, cache behaviors, invalidations, tagging |
| WAF v2 | In-process | Web ACLs, IP sets, regex pattern sets, rule groups, logging configs, resource associations, tagging (REGIONAL and CLOUDFRONT scopes) |
@@ -388,7 +277,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
| Textract | In-process stub | API-compatible stubs, dummy block data, async job simulation |
| Transcribe | In-process stub | Transcription jobs and custom vocabularies; jobs complete immediately, no real audio processing |
| Pricing | In-process with static snapshot | Product discovery, attributes, price list files, pagination |
| Cost Explorer | In-process | Cost synthesized from Floci resource state and pricing snapshots |
| Cost Explorer | In-process | Cost synthesized from the runtime's resource state and pricing snapshots |
| Cost and Usage Reports | In-process with floci-duck sidecar | CUR 2.0 and FOCUS 1.2 columns, account-scoped storage, Parquet emission |
| BCM Pricing Calculator | In-process | Workload estimate create, usage pricing, read, and delete lifecycle |
| BCM Data Exports | In-process | Export lifecycle, executions, update and delete operations |
@@ -397,7 +286,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
## Real Docker Integration
Floci uses real Docker containers when in-process emulation would reduce fidelity. This applies to stateful databases, connection-heavy protocols, runtimes, and build systems.
The runtime uses real Docker containers when in-process emulation would reduce fidelity. This applies to stateful databases, connection-heavy protocols, runtimes, and build systems.
| Service | Default image | What is real |
|---|---|---|
@@ -415,12 +304,12 @@ Floci uses real Docker containers when in-process emulation would reduce fidelit
| EC2 | AMI-mapped Linux images | Linux containers, SSH key injection, UserData, IMDS, IAM credentials |
| ECS | User-specified task image | Container lifecycle, start, stop, health checks |
| EKS | `rancher/k3s:latest` | Kubernetes API server via k3s |
| MWAA | `apache/airflow:<version>-python3.12` + `postgres:16-alpine` | Real Apache Airflow (LocalExecutor: scheduler + webserver) with its own Postgres metadata DB; webserver/CLI reachable via Floci's proxy |
| MWAA | `apache/airflow:<version>-python3.12` + `postgres:16-alpine` | Real Apache Airflow (LocalExecutor: scheduler + webserver) with its own Postgres metadata DB; webserver/CLI reachable via the runtime's proxy |
| CodeBuild | User-specified environment image | Buildspec execution, log streaming, S3 artifact upload |
| OpenSearch | `opensearchproject/opensearch:2` | Full OpenSearch engine with REST API |
| ECR | `registry:2` | OCI-compatible registry for docker push and docker pull |
| Verified Permissions | `floci/floci-sidecar-cedar:1.1.0` | Cedar 4 policy parsing, schema validation and authorization decisions, via a [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
| AppSync | `floci/floci-sidecar-graphql:0.2.0` | graphql-java schema validation, query planning and execution, via a [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
| Verified Permissions | `floci/floci-sidecar-cedar:1.1.0` | Cedar 4 policy parsing, schema validation and authorization decisions, via an upstream [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
| AppSync | `floci/floci-sidecar-graphql:0.2.0` | graphql-java schema validation, query planning and execution, via an upstream [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
Docker-backed services require the Docker socket:
@@ -429,7 +318,7 @@ docker run -d --name floci \
-p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
-u root \
floci/floci:latest
floci-floci:latest
```
### Overriding default images
@@ -453,7 +342,7 @@ docker run -d --name floci \
## Persistence and Storage Modes
Floci can trade speed for durability depending on the workflow. Configure the default mode with `FLOCI_STORAGE_MODE`, or override storage per service.
The runtime can trade speed for durability depending on the workflow. Configure the default mode with `FLOCI_STORAGE_MODE`, or override storage per service.
| Mode | Behavior | Best for | Durability |
|---|---|---|:---:|
@@ -464,22 +353,22 @@ Floci can trade speed for durability depending on the workflow. Configure the de
Use `memory` for fast test runs. Use `hybrid` when you want state preserved across container restarts without much overhead.
For more detail, see the [Storage Configuration documentation](https://floci.io/floci/configuration/storage/).
For more detail, see upstream Floci's [Storage Configuration documentation](https://floci.io/floci/configuration/storage/).
## Multi-Account Isolation
Floci supports per-account resource isolation with no extra setup. If `AWS_ACCESS_KEY_ID` is exactly 12 digits, Floci uses it as the account ID. Resources created by one account are invisible to another.
The runtime supports per-account resource isolation with no extra setup. If `AWS_ACCESS_KEY_ID` is exactly 12 digits, it is used as the account ID. Resources created by one account are invisible to another.
```bash
AWS_ACCESS_KEY_ID=111111111111 aws sqs create-queue --queue-name orders
AWS_ACCESS_KEY_ID=222222222222 aws sqs create-queue --queue-name orders
```
Any other key format, such as `test` or `AKIA...`, causes Floci to fall back to `FLOCI_DEFAULT_ACCOUNT_ID`, which defaults to `000000000000`.
Any other key format, such as `test` or `AKIA...`, falls back to `FLOCI_DEFAULT_ACCOUNT_ID`, which defaults to `000000000000`.
STS temporary credentials are routed too: credentials from `AssumeRole` resolve to the assumed role's account, so the cross-account assume-role-then-provision pattern works locally. Resolution precedence is 12-digit AKID → temporary-session lookup → `FLOCI_DEFAULT_ACCOUNT_ID`.
See the [Multi-Account Isolation docs](https://floci.io/floci/configuration/multi-account/).
See upstream Floci's [Multi-Account Isolation docs](https://floci.io/floci/configuration/multi-account/).
## SDK Integration
@@ -525,7 +414,7 @@ client = boto3.client(
client.put_parameter(
Name="/demo/app/message",
Value="hello from floci",
Value="hello from TheCloud",
Type="String",
Overwrite=True,
)
@@ -551,7 +440,7 @@ const client = new SQSClient({
await client.send(
new SendMessageCommand({
QueueUrl: "http://localhost:4566/000000000000/demo-queue",
MessageBody: "hello from floci",
MessageBody: "hello from TheCloud",
}),
);
```
@@ -612,7 +501,7 @@ use aws_sdk_secretsmanager::Client;
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.credentials_provider(Credentials::new("test", "test", None, None, "floci"))
.credentials_provider(Credentials::new("test", "test", None, None, "thecloud"))
.endpoint_url("http://localhost:4566")
.load()
.await;
@@ -622,7 +511,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
client
.create_secret()
.name("demo/secret")
.secret_string("hello from floci")
.secret_string("hello from TheCloud")
.send()
.await?;
@@ -648,7 +537,7 @@ aws --endpoint-url http://localhost:4566 s3 ls
## Testcontainers
Floci has Testcontainers modules for starting isolated Floci instances directly from tests. This avoids shared state, manual daemon setup, and port conflicts.
Upstream Floci publishes Testcontainers modules for starting isolated instances directly from tests. This avoids shared state, manual daemon setup, and port conflicts. They start upstream's `floci/floci` image by default, so they test against upstream Floci rather than this fork unless you give them your own image.
For Testcontainers 1.x, use the versions as indicated in the table below.
@@ -766,7 +655,7 @@ def test_s3_create_bucket():
## Compatibility Testing
The [`compatibility-tests`](./compatibility-tests/) directory validates Floci across SDKs and tooling workflows.
The [`compatibility-tests`](./compatibility-tests/) directory validates the runtime across SDKs and tooling workflows.
| Module | Language / Tool | SDK / Client | Tests |
|---|---|---|---:|
@@ -783,22 +672,19 @@ The [`compatibility-tests`](./compatibility-tests/) directory validates Floci ac
## Migrating from LocalStack
Floci is a drop-in replacement for LocalStack Community. The port, credentials, SDK configuration, and CLI endpoint pattern work the same way. Swap the image and keep going.
The runtime is a drop-in replacement for LocalStack Community. The port, credentials, SDK configuration, and CLI endpoint pattern work the same way. Swap the image and keep going.
```yaml
# Before
image: localstack/localstack
# After, standard image
image: floci/floci:latest
# After, if init scripts need AWS CLI or boto3
image: floci/floci:latest-compat
# After, your local build of this repository
image: floci-floci:latest
```
LocalStack environment variables are translated automatically:
| LocalStack | Floci equivalent |
| LocalStack | Equivalent |
|---|---|
| `LOCALSTACK_HOST` | `FLOCI_HOSTNAME` |
| `PERSISTENCE=1` | `FLOCI_STORAGE_MODE=persistent` |
@@ -808,43 +694,23 @@ LocalStack environment variables are translated automatically:
Init scripts mounted under `/etc/localstack/init/` run unchanged. The `/_localstack/init` and `/_localstack/health` endpoints are still served. Once the emulator is up, the log also ends with a LocalStack-style `Ready.` line, so tooling that watches the log for it, such as the default wait strategy of Testcontainers' `LocalStackContainer`, works unchanged. Set `LOCALSTACK_PARITY=false` to opt out of automatic translation.
See the [full migration guide](https://floci.io/floci/getting-started/migrate-from-localstack/).
See upstream Floci's [full migration guide](https://floci.io/floci/getting-started/migrate-from-localstack/).
## Image Tags
## Building and Updating
Every tag combines a variant and a channel.
The image is built from this repository; nothing is published to Docker Hub:
| Channel | Standard | Baseline (ARM64 only) | Compat with AWS CLI and boto3 |
|---|---|---|---|
| Release, floating | `latest` | `latest-baseline` | `latest-compat` |
| Release, pinned | `x.y.z` | `x.y.z-baseline` | `x.y.z-compat` |
| Nightly, floating | `nightly` | — | `nightly-compat` |
| Nightly, dated | `nightly-mmddyyyy` | — | `nightly-mmddyyyy-compat` |
Use `latest` for stable releases, a pinned version for reproducible builds, and `nightly` to track `main`.
```yaml
# Recommended
image: floci/floci:latest
# Includes AWS CLI and boto3
image: floci/floci:latest-compat
# ARM64 baseline for Raspberry Pi 4 / pre-LSE cores
image: floci/floci:latest-baseline
# Pinned release
image: floci/floci:x.y.z
# Track main
image: floci/floci:nightly
```bash
docker build -f docker/Dockerfile -t floci-floci:latest .
```
### Release train
Tests run with Maven and JDK 25:
Stable releases ship on the **1st and 3rd Tuesday of each month**. Between trains, `floci/floci:nightly` tracks `main`. Every merged fix is available the next day, and dated `nightly-mmddyyyy` tags let you pin a specific night's build.
```bash
./mvnw test
```
Versions are derived from Conventional Commits by [semantic-release](https://github.com/semantic-release/semantic-release); `CHANGELOG.md` is generated, never hand-edited. Releases are cut from `main` only: there are no maintenance branches.
Upstream fixes come in by cherry-picking commits from Floci's `main` onto a branch, which then goes through review like any other change.
## Configuration
@@ -852,26 +718,26 @@ All settings are overridable through environment variables with the `FLOCI_` pre
| Variable | Default | Description |
|---|---|---|
| `FLOCI_PORT` | `4566` | Port exposed by the Floci API |
| `FLOCI_PORT` | `4566` | Port the runtime's API listens on |
| `FLOCI_DEFAULT_REGION` | `us-east-1` | Default AWS region |
| `FLOCI_DEFAULT_ACCOUNT_ID` | `000000000000` | Default AWS account ID |
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used when Floci returns service URLs |
| `FLOCI_HOSTNAME` | Unset | Hostname used in returned URLs when Floci runs inside Docker Compose |
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used in service URLs the runtime returns |
| `FLOCI_HOSTNAME` | Unset | Hostname used in returned URLs when the runtime runs inside Docker Compose |
| `FLOCI_STORAGE_MODE` | `memory` | Storage mode: `memory`, `persistent`, `hybrid`, or `wal` |
| `FLOCI_STORAGE_PERSISTENT_PATH` | `./data` | Directory used for persisted state |
| `FLOCI_SERVICES_LAMBDA_ECR_BASE_URI` | `public.ecr.aws` | ECR base URI used when pulling Lambda runtime images (legacy name `FLOCI_ECR_BASE_URI` still works) |
| `FLOCI_SERVICES_S3_ENFORCE_AUTH` | `false` | Enforce S3 public/private read access and reject unknown signed S3 access keys |
Full reference: [configuration docs](https://floci.io/floci/configuration/advanced/application-yml)
Full reference: [`src/main/resources/application.yml`](src/main/resources/application.yml), and upstream Floci's [configuration docs](https://floci.io/floci/configuration/advanced/application-yml).
### Multi-container Docker Compose
When your application runs in a different container, set `FLOCI_HOSTNAME` to the Floci service name so returned URLs, such as SQS `QueueUrl` values, resolve correctly.
When your application runs in a different container, set `FLOCI_HOSTNAME` to the runtime's service name so returned URLs, such as SQS `QueueUrl` values, resolve correctly.
```yaml
services:
floci:
image: floci/floci:latest
image: floci-floci:latest
ports:
- "4566:4566"
environment:
@@ -886,59 +752,6 @@ services:
Without this, services may return URLs using `localhost`, which points to the wrong container from the application container.
## Community
Join the Floci community on [Slack](https://join.slack.com/t/floci/shared_invite/zt-3tjn02s3q-A00kEjJ1cZxsg_imTfy6Cw) or [GitHub Discussions](https://github.com/orgs/floci-io/discussions). Feature ideas, compatibility questions, design tradeoffs, and rough proposals are welcome.
## Sponsors
Floci is independent open source, funded by the people and companies who use it.
Sponsorship buys gratitude and nothing else: every emulated service is free for
everyone, forever, and no sponsor gets features, priority, or roadmap influence
that the rest of the Flock does not.
### 🥇 Gold
Large logo with top placement in the emulator READMEs and on floci.io, plus a
mention in release notes.
[IceGuard](https://github.com/iceguard) · [Softmax](https://softmax.com/)
### 🥈 Silver
Logo in the emulator READMEs and on floci.io, plus a mention in release notes.
*Your logo here. [Become a sponsor](https://github.com/sponsors/floci-io).*
### 🥉 Community
Name in the emulator READMEs, a sponsor badge on GitHub, and our sincere thanks.
[AutoScout24](https://www.autoscout24.com) · [Nexxion AI](https://nexxion.ai/)
Every sponsor, including the Friends of the Flock who support Floci outside these
tiers, is listed in [THANKS.md](https://github.com/floci-io/.github/blob/main/THANKS.md).
**[Sponsor Floci](https://github.com/sponsors/floci-io)**
## Star History
<p align="center">
<a href="https://www.star-history.com/?repos=floci-io%2Ffloci&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=floci-io/floci&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=floci-io/floci&type=date&legend=top-left" />
<img width="600" alt="Star History Chart" src="https://api.star-history.com/chart?repos=floci-io/floci&type=date&legend=top-left" />
</picture>
</a>
</p>
## Contributors
<a href="https://github.com/floci-io/floci/graphs/contributors">
<img src="https://contrib.rocks/image?repo=floci-io/floci&max=100&columns=20" />
</a>
## License
MIT. Use it however you want.
MIT. TheCloud's AWS runtime is a fork of [Floci](https://github.com/floci-io/floci); thanks to the Floci contributors and sponsors whose work it builds on.
@@ -0,0 +1,117 @@
package com.floci.test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.ComparisonOperator;
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
import software.amazon.awssdk.services.cloudwatch.model.Metric;
import software.amazon.awssdk.services.cloudwatch.model.MetricAlarm;
import software.amazon.awssdk.services.cloudwatch.model.MetricDataQuery;
import software.amazon.awssdk.services.cloudwatch.model.MetricStat;
import software.amazon.awssdk.services.cloudwatch.model.StandardUnit;
import software.amazon.awssdk.services.cloudwatch.model.Statistic;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("CloudWatch - Metric-math alarm definitions")
class CloudWatchMetricMathAlarmTest {
private CloudWatchClient cloudWatch;
private String alarmName;
@BeforeEach
void setup() {
cloudWatch = TestFixtures.cloudWatchClient();
alarmName = TestFixtures.uniqueName("sdk-metric-math-alarm");
}
@AfterEach
void cleanup() {
try {
cloudWatch.deleteAlarms(request -> request.alarmNames(alarmName));
} finally {
cloudWatch.close();
}
}
@Test
void metricsRoundTripWithoutInventingSingleMetricFields() {
List<MetricDataQuery> metrics = metrics();
putMathAlarm(metrics);
assertMathAlarm(metrics);
}
@Test
void updatesReplaceQueriesAndSwitchBackToASingleMetric() {
putSingleMetricAlarm(300);
List<MetricDataQuery> original = metrics();
putMathAlarm(original);
assertMathAlarm(original);
List<MetricDataQuery> replacement = List.of(
original.get(0).toBuilder().expression("errors / requests").label("Ratio").build(),
original.get(1), original.get(2));
putMathAlarm(replacement);
assertMathAlarm(replacement);
putSingleMetricAlarm(120);
MetricAlarm alarm = describe();
assertThat(alarm.hasMetrics()).isFalse();
assertThat(alarm.period()).isEqualTo(120);
assertThat(alarm.metricName()).isEqualTo("5xx");
assertThat(alarm.namespace()).isEqualTo("AWS/ApiGateway");
}
private void putMathAlarm(List<MetricDataQuery> metrics) {
cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName)
.comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD)
.evaluationPeriods(2).threshold(5.0).metrics(metrics));
}
private void putSingleMetricAlarm(int period) {
cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName)
.comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD)
.evaluationPeriods(2).threshold(5.0).namespace("AWS/ApiGateway")
.metricName("5xx").statistic(Statistic.SUM).period(period));
}
private MetricAlarm describe() {
List<MetricAlarm> alarms = cloudWatch.describeAlarms(request -> request.alarmNames(alarmName)).metricAlarms();
assertThat(alarms).hasSize(1);
return alarms.get(0);
}
private void assertMathAlarm(List<MetricDataQuery> metrics) {
MetricAlarm alarm = describe();
assertThat(alarm.metrics()).containsExactlyElementsOf(metrics);
assertThat(alarm.period()).isNull();
assertThat(alarm.metricName()).isNull();
assertThat(alarm.namespace()).isNull();
assertThat(alarm.statistic()).isNull();
assertThat(alarm.extendedStatistic()).isNull();
assertThat(alarm.unit()).isNull();
assertThat(alarm.hasDimensions()).isFalse();
assertThat(alarm.evaluationPeriods()).isEqualTo(2);
assertThat(alarm.threshold()).isEqualTo(5.0);
}
private static List<MetricDataQuery> metrics() {
return List.of(
MetricDataQuery.builder().id("rate").expression("100 * errors / requests")
.returnData(true).period(300).label("Error <rate> & requests").build(),
MetricDataQuery.builder().id("errors").returnData(false).accountId("123456789012")
.metricStat(MetricStat.builder().period(300).stat("Sum").unit(StandardUnit.COUNT)
.metric(Metric.builder().namespace("AWS/ApiGateway").metricName("5xx")
.dimensions(Dimension.builder().name("ApiName").value("api<&>").build())
.build()).build()).build(),
MetricDataQuery.builder().id("requests").returnData(false)
.metricStat(MetricStat.builder().period(300).stat("Sum")
.metric(Metric.builder().namespace("AWS/ApiGateway").metricName("Count").build())
.build()).build());
}
}
@@ -6,6 +6,7 @@ import software.amazon.awssdk.services.cloudwatch.model.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
@@ -185,4 +186,67 @@ class CloudWatchTest {
);
assertThat(noMatch.datapoints()).isEmpty();
}
@Test
@DisplayName("PutMetricData stores datums nested under EntityMetricData")
void testPutMetricDataWithEntityMetricData() {
String namespace = "JavaTestEntityMetricData";
Instant now = Instant.now();
cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.metricData(datum -> datum
.metricName("TopLevel")
.value(1.0)
.timestamp(now))
.entityMetricData(entityMetricData -> entityMetricData
.entity(entity -> entity
.keyAttributes(Map.of(
"Type", "AWS::Resource",
"ResourceType", "AWS::EC2::Instance",
"Identifier", "i-0123456789abcdef0")))
.metricData(MetricDatum.builder()
.metricName("mem_used_percent")
.value(6.5)
.unit(StandardUnit.PERCENT)
.storageResolution(60)
.timestamp(now)
.dimensions(Dimension.builder().name("host").value("h1").build())
.build()))
.strictEntityValidation(false)
);
ListMetricsResponse listed = cloudWatchClient.listMetrics(request -> request.namespace(namespace));
assertThat(listed.metrics())
.extracting(Metric::metricName)
.containsExactlyInAnyOrder("TopLevel", "mem_used_percent");
GetMetricStatisticsResponse stats = cloudWatchClient.getMetricStatistics(request -> request
.namespace(namespace)
.metricName("mem_used_percent")
.dimensions(Dimension.builder().name("host").value("h1").build())
.startTime(now.minus(1, ChronoUnit.HOURS))
.endTime(now.plus(1, ChronoUnit.MINUTES))
.period(3600)
.statistics(Statistic.SUM)
);
assertThat(stats.datapoints()).hasSize(1);
assertThat(stats.datapoints().get(0).sum()).isEqualTo(6.5);
}
@Test
@DisplayName("PutMetricData without MetricName throws MissingRequiredParameterException")
void testPutMetricDataWithoutMetricNameIsRejected() {
String namespace = "JavaTestMissingMetricName";
assertThatThrownBy(() -> cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.entityMetricData(entityMetricData -> entityMetricData
.metricData(MetricDatum.builder().value(1.0).build()))))
.isInstanceOf(MissingRequiredParameterException.class)
.satisfies(e -> assertThat(((MissingRequiredParameterException) e).awsErrorDetails().errorCode())
.isEqualTo("MissingParameter"));
assertThat(cloudWatchClient.listMetrics(request -> request.namespace(namespace)).metrics()).isEmpty();
}
}
@@ -203,6 +203,8 @@ class TestCloudWatchAlarms:
alarm = next(a for a in alarms if a["AlarmName"] == alarm_name)
assert alarm["StateValue"] == "INSUFFICIENT_DATA"
assert response["CompositeAlarms"] == []
assert response["LogAlarms"] == []
finally:
cloudwatch_client.delete_alarms(AlarmNames=[alarm_name])
+5
View File
@@ -12,6 +12,11 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
# Optional SES SMTP relay settings (FLOCI_SERVICES_SES_SMTP_*), kept outside the repo
# because they hold a password. Without the file, sent emails stay in the SES mailbox.
env_file:
- path: ${HOME}/.config/thecloud/ses-smtp.env
required: false
environment:
FLOCI_SERVICES_DOCKER_NETWORK: floci_default
FLOCI_SERVICES_RDS_PROXY_BASE_PORT: "7001"
@@ -0,0 +1,11 @@
FROM public.ecr.aws/amazonlinux/amazonlinux:2023
RUN dnf install -y openssh-clients openssh-server systemd \
&& dnf clean all \
&& systemctl enable sshd
COPY motd /etc/motd
ENV container=docker
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]
@@ -0,0 +1,10 @@
# Amazon Linux 2023 EC2 guest
Build the local guest image before launching `ami-0abcdef1234567891`:
```text
docker build --platform linux/amd64 -t floci/ami-amazonlinux:2023 docker/ec2/ami-images/amazonlinux-2023
```
The image runs systemd, provides SSH, and uses the Amazon Linux 2023 login banner.
Allow TCP 80 in an instance security group to have Floci publish an Apache service on an allocated host port.
@@ -0,0 +1,3 @@
__| __|_ )
_| ( / Amazon Linux 2023
___|\___|___|
@@ -0,0 +1,22 @@
FROM docker.io/library/debian:trixie-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
systemd \
systemd-sysv \
openssh-client \
openssh-server \
iproute2 \
iptables \
socat \
curl \
ca-certificates \
&& systemctl enable ssh \
&& printf '#!/bin/sh\nmkdir -p /run/sshd\nexec "$@"\n' > /usr/local/sbin/floci-init \
&& chmod +x /usr/local/sbin/floci-init \
&& rm -rf /var/lib/apt/lists/*
ENV container=docker
STOPSIGNAL SIGRTMIN+3
ENTRYPOINT ["/usr/local/sbin/floci-init"]
CMD ["/sbin/init"]
+10
View File
@@ -0,0 +1,10 @@
# Debian 13 EC2 guest
Build the local guest image before launching `ami-debian13`:
```text
docker build --platform linux/amd64 -t floci/ami-debian:13 docker/ec2/ami-images/debian-13
```
The image runs systemd and starts SSH on every EC2 boot. Enable application services with
`systemctl enable --now <service>` so they restart after `StopInstances` and `StartInstances`.
@@ -0,0 +1,17 @@
FROM registry.fedoraproject.org/fedora:42
RUN dnf install -y \
systemd \
openssh-clients \
openssh-server \
iproute \
iptables \
socat \
curl \
ca-certificates \
&& dnf clean all \
&& systemctl enable sshd
ENV container=docker
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]
+10
View File
@@ -0,0 +1,10 @@
# Fedora 42 systemd EC2 guest
Build the local guest image before launching `ami-fedora42`:
```text
docker build --platform linux/amd64 -t floci/ami-fedora:42 docker/ec2/ami-images/fedora-42
```
The image runs systemd and starts SSH on every EC2 boot. Enable application services with
`systemctl enable --now <service>` so they restart after `StopInstances` and `StartInstances`.
@@ -0,0 +1,4 @@
FROM registry.opensuse.org/opensuse/leap:15.6
RUN zypper --non-interactive install -y openssh iproute2 iptables socat curl ca-certificates \
&& zypper clean --all
@@ -0,0 +1,9 @@
# openSUSE Leap 15.6 EC2 guest
Build the local guest image before launching `ami-opensuse-leap156`:
```text
docker build --platform linux/amd64 -t floci/ami-opensuse:15.6 docker/ec2/ami-images/opensuse-leap-15.6
```
The image includes OpenSSH and the EC2 metadata proxy dependencies.
@@ -0,0 +1,19 @@
FROM public.ecr.aws/docker/library/ubuntu:20.04
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
systemd \
systemd-sysv \
openssh-client \
openssh-server \
iproute2 \
iptables \
socat \
curl \
ca-certificates \
&& systemctl enable ssh \
&& rm -rf /var/lib/apt/lists/*
ENV container=docker
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]
@@ -0,0 +1,10 @@
# Ubuntu 20.04 systemd EC2 guest
Build the local guest image before launching `ami-0abcdef1234567892`:
```text
docker build --platform linux/amd64 -t floci/ami-ubuntu:20.04 docker/ec2/ami-images/ubuntu-20.04
```
The image runs systemd and starts SSH on every EC2 boot. Enable application services with
`systemctl enable --now <service>` so they restart after `StopInstances` and `StartInstances`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1 -1
View File
@@ -160,7 +160,7 @@ cross-resource references.
| Kinesis Data Firehose | `DeliveryStream` |
| IoT Core | `DomainConfiguration` (`ServerCertificates` resolves to a JSON string), `Policy` (deleted after detaching it from its principals; on AWS the delete fails with `DeleteConflictException` while the policy is attached), `Thing`, `TopicRule` |
| CloudFront | `CachePolicy`, `Distribution`, `OriginAccessControl`, `OriginRequestPolicy`, `ResponseHeadersPolicy` |
| CloudWatch | `Alarm`, `Dashboard` |
| CloudWatch | `Alarm`, `CompositeAlarm`, `Dashboard`. Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and replacement cleanup. Composite actions are stored only; suppression properties are rejected. |
| CloudWatch Logs | `LogGroup`, `LogStream`, `MetricFilter` |
| WAFv2 | `WebACL` |
| Config | `ConfigRule` |
+59 -8
View File
@@ -3,7 +3,8 @@
CloudFront management-plane and local content-delivery emulation. Supports distribution lifecycle,
cache policies, origin request policies, response headers policies, origin access controls, origin
access identities, public keys, trusted key groups, CloudFront Functions, invalidations, tagging, and
GET/HEAD/OPTIONS delivery from S3 or custom origins.
viewer delivery from S3 or custom origins for every CloudFront viewer method (GET, HEAD, OPTIONS, POST,
PUT, PATCH and DELETE).
**Protocol:** REST XML
**API version:** `2020-05-31`
@@ -150,9 +151,17 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
- CNAME aliases are globally unique. `AssociateAlias` atomically transfers an alias from its current
owner to the target distribution. Exact aliases take precedence over the most-specific matching
wildcard alias.
- Viewer GET/HEAD requests, and OPTIONS requests allowed by the matched cache behavior, addressed to
an enabled distribution's generated domain or alias are routed to the matching S3 or custom
origin. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
- Viewer requests addressed to an enabled distribution's generated domain or alias are routed to the
matching S3 or custom origin when the matched cache behavior's `AllowedMethods` includes the
method; any other method returns 403 `Invalid method.`. A behavior without `AllowedMethods` allows
GET and HEAD. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
- POST, PUT, PATCH and DELETE are forwarded to custom origins with the viewer request body and its
`Content-Type`. The body is streamed to the origin with the viewer's `Content-Length`, or chunked
when the viewer sent it chunked or, as HTTP/2 allows, with no length, so it is never held in
memory. Their responses are never cached, as on AWS. Signed URL and signed cookie enforcement
applies to them like any other method. Request bodies are bounded by the emulator-wide
`floci.protocols.max-request-size` limit (`FLOCI_PROTOCOLS_MAX_REQUEST_SIZE`, 2048 MB by default);
CloudFront accepts up to 64 GB, so raise it for larger uploads.
- Every distribution is also served as `{id}.cloudfront.{host}` for each endpoint host Floci
resolves: `localhost`, `localhost.floci.io`, `localhost.localstack.cloud`, `FLOCI_HOSTNAME` and
every `FLOCI_DNS_EXTRA_SUFFIXES` entry. `{id}.cloudfront.localhost.floci.io` and
@@ -160,7 +169,7 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
HTTPS certificate, so a signed URL for either can be downloaded over `https://`. See
[Downloading over HTTPS](#downloading-over-https).
- Origin custom headers are persisted through the CloudFront API and CloudFormation. They replace
same-named viewer headers on custom-origin GET/HEAD/OPTIONS requests. For in-process S3 origins, a
every same-named forwarded viewer header on custom-origin requests. For in-process S3 origins, a
configured `Origin` header is used for S3 CORS evaluation. AWS-prohibited names, malformed
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
CloudFront errors when the distribution is created or updated.
@@ -181,9 +190,48 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
service-principal bucket-policy grants (including the distribution `AWS:SourceArn`) when strict S3
authentication is enabled. OAC `always`, `never`, and unsigned `no-override` requests follow their
documented signing behavior; signed `no-override` viewer requests retain their authorization.
- Cache-policy, origin-request-policy, and legacy `ForwardedValues` data-plane evaluation is not
implemented yet. Viewer query strings therefore follow CloudFront's default behavior and are not
forwarded to origins.
- Requests to custom origins carry what the matched cache behavior forwards. With a cache policy,
the viewer headers, cookies and query strings selected by the cache policy's
`ParametersInCacheKeyAndForwardedToOrigin` and by the origin request policy are forwarded (the
union of both). `HeaderBehavior` `none`, `whitelist`, `allViewer`,
`allViewerAndWhitelistCloudFront` and `allExcept`, `CookieBehavior` and `QueryStringBehavior`
`none`, `whitelist`, `allExcept` and `all` are evaluated. A cache policy that selects nothing and no
origin request policy forwards no viewer headers, cookies or query strings, as on AWS.
- A cache behavior without a cache policy uses its legacy `ForwardedValues`: `QueryString=true`
forwards every query string (`QueryStringCacheKeys` only narrows the cache key), cookies follow
`Forward` `none`, `whitelist` (with `*` and `?` wildcards) or `all`, and viewer headers are
forwarded by default except `Accept`, `Accept-Charset`, `Accept-Language`, `Authorization`,
`Host`, `Referer`, `User-Agent` and `X-HTTP-Method-Override`, which `Headers` can add back (`*`
forwards all of them). An origin request policy needs a cache policy on AWS, so a behavior without
one ignores it.
- Whatever the policies, CloudFront sets `Host` to the origin's domain name unless the viewer's
`Host` is forwarded, sends `User-Agent: Amazon CloudFront` unless the viewer's `User-Agent` is
forwarded, appends the viewer address to `X-Forwarded-For`, adds a
`Via: <viewer HTTP version> <distribution domain> (CloudFront)` hop and an `X-Amz-Cf-Id`.
`Authorization` is always forwarded on POST, PUT, PATCH and DELETE, and on OPTIONS unless OPTIONS
is a cached method; `Content-Type` travels with a request body. Cache policy compression settings
normalize `Accept-Encoding` to `br,gzip`, `gzip`, `br` or `identity`. Forwarded cookies are sorted
by name, and cookies that are not `name=value` or whose name starts with `$` are dropped.
Hop-by-hop headers, `Expect`, `X-Edge-*`, `X-Real-IP`, `X-Forwarded-Proto` and viewer-supplied
`CloudFront-*` headers are never forwarded.
- Of the CloudFront request headers, `CloudFront-Forwarded-Proto`, `CloudFront-Viewer-Address` and
`CloudFront-Viewer-Http-Version` are generated when a policy names them, or when an `allExcept`
header behavior does not exclude them (AWS documents Managed-AllViewerExceptHostHeader as adding
them). Device-type, geolocation, TLS, JA3/JA4 and header-order headers are not generated.
- The eight AWS managed origin request policies are available by their AWS IDs through
`GetOriginRequestPolicy` and `ListOriginRequestPolicies` (`Type` is `managed` or `custom`), and
cannot be updated or deleted. AWS managed cache policies are not modeled: a behavior that
references one forwards nothing on its account, which matches `CachingDisabled`,
`CachingOptimized` and `CachingOptimizedForUncompressedObjects` but not `Amplify`,
`Elemental-MediaPackage` or the `UseOriginCacheControlHeaders` policies.
- In-process S3 origins do not apply forwarding settings: viewer query strings, cookies and headers
do not change the S3 read.
- Origin responses are streamed to the viewer, from in-process S3 origins and custom origins alike,
so a large object is never held in memory. A custom origin's `Content-Length` is passed on, and a
response it sends chunked stays chunked. Floci does not cache, so it serves objects larger than
50 GB the way CloudFront does with caching disabled; with caching enabled, CloudFront refuses them.
- Origin `Set-Cookie` headers always reach the viewer. With legacy `Forward=none`, AWS strips them
from the response; Floci does not.
- Custom origins that resolve to loopback, private, link-local, carrier-grade NAT, or other non-routable addresses are rejected by default. Development-only private origins must be explicitly allowlisted by exact hostname.
- Response headers policies validate the AWS configuration shape and are applied after the origin
response, including CORS preflight fields, origin override behavior, custom headers, security
@@ -339,3 +387,6 @@ as it is.
- CloudFormation provisioning of custom `AWS::CloudFront::ResponseHeadersPolicy` resources
(literal custom or managed policy IDs are supported on distributions)
- Persistent edge caching and global CDN propagation
- Forwarding POST, PUT, PATCH and DELETE to S3 origins. AWS passes them to S3, which evaluates them
against the bucket policy (OAC supports `PUT` and `DELETE`); Floci's in-process S3 origin answers
them with 403 `Access Denied`, as S3 does for a request that holds no write grant.
+26
View File
@@ -311,6 +311,7 @@ aws logs put-retention-policy \
| `GetMetricStatistics` | Get metric statistics (Average, Sum, etc.) |
| `GetMetricData` | Query metrics with math expressions |
| `PutMetricAlarm` | Create a metric alarm |
| `PutCompositeAlarm` | Create or update a composite alarm rule |
| `DescribeAlarms` | List alarms |
| `DeleteAlarms` | Delete alarms |
| `SetAlarmState` | Manually set alarm state |
@@ -331,6 +332,31 @@ aws logs put-retention-policy \
Metric streams are stored as definitions with their `running` or `stopped` state. Floci never
delivers metrics to the Firehose delivery stream a metric stream names.
Composite alarms support boolean rules over alarm states. Request `AlarmTypes` containing
`CompositeAlarm` when describing them; the default describes metric alarms. Composite
action lists are stored but not executed. Action suppression is not implemented and is
rejected with `ValidationError`.
### SNS alarm actions
State transitions publish to same-account standard SNS topics in `AlarmActions`, `OKActions`, or
`InsufficientDataActions`, including transitions from `SetAlarmState`. Unchanged states and
`ActionsEnabled=false` suppress notifications. Scaling action retries remain independent.
Failures are logged per target without blocking other targets. Notifications include a
single-metric `Trigger`; durable retries, FIFO topics, and metric-math payloads are not covered.
### Metric-math alarm definitions
`PutMetricAlarm` stores the `Metrics` query definitions and `DescribeAlarms` returns them over
both JSON and Query protocols. Expressions, labels, `ReturnData`, account IDs, query periods,
and nested metric statistics (including dimensions and units) survive storage reloads.
Metric-math alarm responses omit the single-metric fields, including top-level `Period`.
Updating an alarm replaces its metric definition, so changing between `Metrics` and a single
metric does not retain the previous definition.
This is management-plane support: the alarm evaluator currently evaluates only single-metric
alarms, not metric-math expressions. `SetAlarmState` remains available for manual transitions.
### Examples
```bash
+2 -2
View File
@@ -109,8 +109,8 @@ For `computePlatform: ECS`, `CreateDeployment` performs a full blue/green traffi
3. Runs lifecycle hook Lambdas in order: `BeforeInstall` → (install) → `AfterInstall` → `BeforeAllowTraffic` → (traffic shift) → `AfterAllowTraffic`
4. **Traffic shift** — atomically updates the ELB v2 listener's default forward rule:
- `ECSAllAtOnce`: immediately shifts 100% of traffic to the green target group
- `ECSCanary*`: shifts the canary percentage first, waits a short interval (capped at 5 s in emulator), then shifts to 100%
- `ECSLinear*`: shifts traffic in equal increments (capped at 2 s per step in emulator)
- `ECSCanary*`: shifts the canary percentage first, waits the configured interval, then shifts to 100%
- `ECSLinear*`: shifts traffic in equal increments at the configured interval
5. Promotes the green task set to **PRIMARY** on the ECS service and deletes the original blue task set
6. Marks the deployment `Succeeded`; if any lifecycle hook reports `Failed`, the deployment is marked `Failed`
+33 -10
View File
@@ -75,9 +75,9 @@ AWS-shaped `/oauth2/authorize` and `/oauth2/idpresponse` endpoints to the config
OIDC provider, exchanges the returned code, provisions or reconciles the federated user,
and issues a Cognito authorization code for the registered callback.
Only `ProviderType=OIDC` is supported by this flow. Floci does not provide a built-in hosted
UI and does not implement social providers such as Google, Facebook, Login with Amazon or
SignInWithApple.
Only `ProviderType=OIDC` is supported by this flow. Floci does not implement social providers
such as Google, Facebook, Login with Amazon or SignInWithApple. The pool's own users sign in on
the hosted login page instead (see Well-Known And OAuth Endpoints below).
Two deliberate divergences from AWS, both consequences of not calling out to a third
party:
@@ -280,8 +280,8 @@ up, so an oversized request naming something that does not exist reports the req
problem rather than `ResourceNotFoundException`, and an update violating both reports them
in one message with the asset list first.
Branding is presentation for the hosted UI, which Floci does not serve, so it is stored
and returned rather than rendered. Two divergences follow from that:
Branding is presentation for the hosted UI. Floci's hosted login page does not apply it, so it
is stored and returned rather than rendered. Two divergences follow from that:
- **`Settings` is stored opaquely.** AWS validates it against a deep schema, rejecting
unknown properties with `Invalid settings provided. Validation errors: [{property:
@@ -304,17 +304,40 @@ and returned rather than rendered. Two divergences follow from that:
| `GET /{userPoolId}/.well-known/jwks.json` | JSON Web Key Set for JWT validation |
| `GET /cognito-idp/oauth2/authorize` | OIDC authorization-code start endpoint |
| `GET /cognito-idp/oauth2/idpresponse` | OIDC provider callback endpoint |
| `POST /cognito-idp/oauth2/token` | OAuth authorization-code and client-credentials token endpoint |
| `GET, POST /cognito-idp/login` | Hosted login page for the pool's own users |
| `GET /cognito-idp/logout` | Ends the hosted login session |
| `POST /cognito-idp/oauth2/token` | OAuth authorization-code, refresh-token and client-credentials token endpoint |
The OAuth endpoints support browser-style OIDC authorization-code sign-in as well as the
emulator-friendly client-credentials flow:
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback, then redirects
to the configured provider with an opaque state and nonce.
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback. With
`identity_provider` naming an OIDC provider it redirects there with an opaque state and nonce.
Without `identity_provider`, or with `identity_provider=COGNITO`, the pool's own users sign in
on the hosted login page; the client must list `COGNITO` in `SupportedIdentityProviders` (the
default when none are given).
- `GET /cognito-idp/login` shows a user name (or email) and password form when the client lists
`COGNITO`, and a "Continue with <provider>" button for each of its other
`SupportedIdentityProviders`, which starts the federated sign-in above with the same authorize
parameters. A correct password
redirects to the registered callback with a one-time authorization code and `state`, and starts
a one-hour browser session (the `cognito` cookie), so the next authorize request returns a code
without the form unless it sends `prompt=login`. Wrong passwords, unknown users and
unconfirmed users get the same answer until the password is right. Users with a temporary
password, MFA or a pending reset are told so; those steps are not served on the page yet.
- `GET /cognito-idp/logout?client_id=...&logout_uri=...` ends the session and redirects to
`logout_uri`, which must be one of the client's `LogoutURLs`. With `redirect_uri` and
`response_type=code` instead, it ends the session and shows the login page again.
- PKCE: send `code_challenge` with `code_challenge_method=S256` to authorize, and the matching
`code_verifier` when redeeming the code. `nonce` is copied into the ID token.
- Codes from the hosted login and from federated sign-in fire the Pre Token Generation trigger
with `triggerSource=TokenGeneration_HostedAuth`, and the hosted login fires Pre and Post
Authentication.
- `GET /cognito-idp/oauth2/idpresponse` consumes the provider state, exchanges the provider
code and redirects to the registered callback with a one-time Cognito authorization code.
- `POST /cognito-idp/oauth2/token` redeems that authorization code once, or issues a machine
token for `grant_type=client_credentials`.
- `POST /cognito-idp/oauth2/token` redeems that authorization code once, exchanges a
`refresh_token` for new access and ID tokens, or issues a machine token for
`grant_type=client_credentials`.
`POST /cognito-idp/oauth2/token` is intentionally emulator-friendly rather than full Cognito parity:
+4 -2
View File
@@ -26,14 +26,16 @@ metadata.
| AMI ID | Aliases | Docker image |
|---|---|---|
| `ami-0abcdef1234567890` | `ami-amazonlinux2` | `public.ecr.aws/amazonlinux/amazonlinux:2` |
| `ami-0abcdef1234567891` | `ami-amazonlinux2023` | `public.ecr.aws/amazonlinux/amazonlinux:2023` |
| `ami-0abcdef1234567892` | `ami-ubuntu2004` | `public.ecr.aws/docker/library/ubuntu:20.04` |
| `ami-0abcdef1234567891` | `ami-amazonlinux2023` | `floci/ami-amazonlinux:2023` |
| `ami-0abcdef1234567892` | `ami-ubuntu2004` | `floci/ami-ubuntu:20.04` |
| `ami-ubuntu2204` | | `public.ecr.aws/docker/library/ubuntu:22.04` |
| `ami-ubuntu2404-arm64` | `ami-ubuntu2404` | `public.ecr.aws/docker/library/ubuntu:24.04` |
| `ami-ubuntu2404-amd64` | | `public.ecr.aws/docker/library/ubuntu:24.04` |
| `ami-ubuntu2404-cloud-arm64` | `ami-ubuntu2404-cloud` | `floci/ami-ubuntu:24.04-arm64` |
| `ami-debian12` | | `public.ecr.aws/docker/library/debian:12` |
| `ami-debian13` | | `floci/ami-debian:13` |
| `ami-alpine` | | `public.ecr.aws/docker/library/alpine:latest` |
| `ami-fedora42` | | `floci/ami-fedora:42` |
| `ami-0abcdef1234567893` | | `public.ecr.aws/amazonlinux/amazonlinux:2023` |
Any unrecognized AMI ID (including real AWS AMI IDs like `ami-0abc12345678`) falls back to the catalog `defaultDockerImage` (`public.ecr.aws/amazonlinux/amazonlinux:2023` by default).
+11 -1
View File
@@ -170,7 +170,17 @@ aws elbv2 delete-target-group \
| Environment variable | Default | Description |
|---|---|---|
| `FLOCI_SERVICES_ELBV2_ENABLED` | `true` | Enable or disable the ELBv2 service |
| `FLOCI_SERVICES_ELBV2_LISTENER_FORWARDER_IMAGE` | `alpine/socat:1.8.1.3` | Pinned sidecar image that publishes ALB listener ports from a Docker-hosted Floci container. |
## Listener Ports
Listener sockets bind on the Floci host. Expose any listener ports you need in Docker Compose when Floci itself runs in a container, similar to RDS and ElastiCache proxy ports.
ALB DNS names default to `name-id.elb.localhost`, which resolves to the host loopback address.
When Floci runs in Docker, it creates a lifecycle-managed `socat` sidecar per active listener
port. The sidecar publishes that same port on the host and forwards it to Floci, so no static
Docker Compose port range is required. A host-port conflict prevents that listener from being
reachable until the conflict is removed.
Instance targets use their container bridge IP as the primary route. When Floci cannot route to
that bridge IP, such as Docker Desktop or DinD isolation, it falls back to the instance's existing
published application port through `host.docker.internal`. This fallback is conditional; direct
bridge routing remains the normal path whenever it is reachable.
+1
View File
@@ -65,6 +65,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac
| [RDS Data API](rds-data.md) | `/Execute`, `/BeginTransaction`, `/CommitTransaction`, `/RollbackTransaction` | REST JSON | 4 |
| [Timestream for InfluxDB](timestream-influxdb.md) | `POST /` + `X-Amz-Target: AmazonTimestreamInfluxDB.*` + InfluxDB container | JSON 1.0 + InfluxDB HTTP | 24 |
| [MSK](msk.md) | `/v1/clusters/...`, `/api/v2/clusters/...` + Redpanda broker | REST JSON + Kafka | 8 |
| [MSK Connect](kafka-connect.md) | `/v1/custom-plugins`, `/v1/connectors`, `/v1/worker-configurations` + Kafka Connect worker | REST JSON + Kafka Connect | 13 |
| [Amazon MQ](amazonmq.md) | `/v1/brokers/...` + RabbitMQ broker | REST JSON + AMQP | 5 |
| [MWAA](mwaa.md) | `/` REST paths for environments + CLI/web proxy | REST JSON | 10 |
| [Athena](athena.md) | `POST /` + `X-Amz-Target: AmazonAthena.*` | JSON 1.1 | 4 |
+45
View File
@@ -0,0 +1,45 @@
# MSK Connect
**Protocol:** REST JSON
**Signing name:** `kafkaconnect`
Floci implements the MSK Connect control plane separately from MSK. Custom plugins are ZIP
archives stored in Floci S3. A connector starts a pinned stock Apache Kafka Connect worker, extracts
the referenced ZIP, and mounts only that extracted plugin directory into the worker.
## Supported Actions
| Action | Description |
|---|---|
| `CreateCustomPlugin` | Register a ZIP custom plugin from an S3 bucket ARN and key |
| `ListCustomPlugins` | List custom plugins |
| `DescribeCustomPlugin` | Describe a custom plugin |
| `DeleteCustomPlugin` | Delete a custom plugin |
| `CreateWorkerConfiguration` | Create a worker properties configuration |
| `ListWorkerConfigurations` | List worker configurations |
| `DescribeWorkerConfiguration` | Describe a worker configuration |
| `DeleteWorkerConfiguration` | Delete a worker configuration |
| `CreateConnector` | Start a connector using a custom plugin |
| `ListConnectors` | List connectors |
| `DescribeConnector` | Describe a connector |
| `UpdateConnector` | Update connector configuration |
| `DeleteConnector` | Stop and delete a connector |
## Worker behavior
Workers use the internal Redpanda address of a Floci-managed MSK cluster. The public bootstrap
address supplied to `CreateConnector` is resolved to that cluster but is never passed into the
container. This keeps the worker on Floci's Docker network while host clients continue using the
public MSK bootstrap endpoint.
No connector plugin is bundled. To run an S3 sink, upload a compatible S3 sink connector ZIP to
Floci S3, create a custom plugin for it, and use that plugin in `CreateConnector`. The worker's
container logs are streamed to CloudWatch Logs under `/aws/msk-connect/{connectorName}`.
## Configuration
| Variable | Default | Description |
|---|---|---|
| `FLOCI_SERVICES_KAFKACONNECT_ENABLED` | `true` | Enable or disable MSK Connect |
| `FLOCI_SERVICES_KAFKACONNECT_MOCK` | `false` | Persist control-plane metadata without starting workers |
| `FLOCI_SERVICES_KAFKACONNECT_WORKER_IMAGE` | `apache/kafka:3.9.0` | Pinned stock Kafka Connect worker image |
+9
View File
@@ -91,6 +91,15 @@ TPS over 10 will trigger the LimitExceededException"), independent of the ten-ca
limit above. Floci does not simulate real-time request throttling for this or any other
action, so that limit is intentionally unenforced here.
## Current Limitations
- `ListStreams`, `ListShards`, `ListTagsForStream`, and `ListStreamConsumers` do not yet
support AWS pagination tokens. `ListShards` honors `MaxResults`, but does not return a
continuation token.
- Resource policy actions are not implemented.
- KMS encryption settings are recorded in stream metadata, but record payloads are not
cryptographically encrypted.
## Configuration
| Variable | Default | Description |
+1
View File
@@ -16,6 +16,7 @@ The local implementation keeps Lightsail state in Floci storage and is designed
- Key pairs: create, import, get, list, delete, and download default key pair
- Discovery: regions, blueprints, bundles, active names, operations, and operations for resource
- Tags: tag and untag local Lightsail resources
- Node.js instances: the `nodejs` blueprint uses the multi-architecture `node:24-bookworm` current LTS image, supports the selected SSH key, and persists application state in a named volume at `/opt/lightsail/app`. The volume starts with `releases/<timestamp>` and `current`; the in-container supervisor runs `npm start` from `current`, captures stdout and stderr under `logs/`, restarts crashes with capped exponential backoff, and retains the three newest releases. Application deployment remains SSH/manual in this phase: no deployment API or console workflow is provided.
Read-only list operations for cloud-side Lightsail resource families that are not locally implemented yet return empty AWS-shaped lists. Cloud-only provisioning workflows such as container services, distributions, managed databases, load balancers, buckets, and snapshots are recognized from the AWS Lightsail API model and return an explicit `UnsupportedOperation` error instead of a custom endpoint or non-AWS response shape.
+2 -1
View File
@@ -83,9 +83,10 @@ Static website requests use a bucket website hostname such as
- index-document resolution for the site root and slash-terminated prefixes
- `302` redirects that append a slash when an index document exists below a prefix
- configured error documents and the default website error response
- `RedirectAllRequestsTo` with a required hostname and optional `HTTP` or `HTTPS` protocol. Website GET and HEAD return a `301` absolute redirect that preserves the request path and query string.
HEAD returns the same status and object metadata as GET without a response body.
Redirect-all and advanced routing-rule configurations are not implemented.
Advanced routing-rule configurations are not implemented.
## Range Reads
+9
View File
@@ -48,6 +48,15 @@ changes to `StateMachineName` or `StateMachineType` use replacement semantics.
## Execution history events
## Execution pagination
`ListExecutions` and `GetExecutionHistory` support `maxResults` from 0 to 1000, with AWS's
default page size of 100. Both return opaque `nextToken` values bound to the request arguments
and valid for 24 hours. `ListExecutions` is newest first, ordered by start date and then execution
ARN. The same-millisecond tie-break is covered by focused unit tests; the live acceptance suite
does not force identical execution timestamps. `GetExecutionHistory` is ascending by event ID by
default and supports `reverseOrder`.
`GetExecutionHistory` emits an event family around a Task state's `TaskStateEntered` and
`TaskStateExited` pair. Which family depends on the resource type.
+1
View File
@@ -142,6 +142,7 @@ nav:
- RDS Data API: services/rds-data.md
- Timestream for InfluxDB: services/timestream-influxdb.md
- MSK (Kafka): services/msk.md
- MSK Connect: services/kafka-connect.md
- Amazon MQ: services/amazonmq.md
- Glue: services/glue.md
- Lake Formation: services/lakeformation.md
@@ -302,6 +302,7 @@ public interface EmulatorConfig {
interface ServiceStorageOverrides {
SsmStorageConfig ssm();
KafkaConnectStorageConfig kafkaconnect();
SqsStorageConfig sqs();
S3StorageConfig s3();
DynamoDbStorageConfig dynamodb();
@@ -365,6 +366,13 @@ public interface EmulatorConfig {
long flushIntervalMs();
}
interface KafkaConnectStorageConfig {
Optional<String> mode();
@WithDefault("5000")
long flushIntervalMs();
}
interface SqsStorageConfig {
Optional<String> mode();
}
@@ -673,6 +681,7 @@ public interface EmulatorConfig {
ApiGatewayServiceConfig apigateway();
IamServiceConfig iam();
MskServiceConfig msk();
KafkaConnectServiceConfig kafkaconnect();
AmazonMqServiceConfig amazonmq();
KinesisAnalyticsServiceConfig kinesisAnalytics();
ElastiCacheServiceConfig elasticache();
@@ -1031,6 +1040,18 @@ public interface EmulatorConfig {
interface EfsServiceConfig {
@WithDefault("true")
boolean enabled();
/**
* Serve every mount target over NFSv4.1 at its own address on the VPC's Docker network,
* from a privileged helper container running the kernel NFS server, so an EC2 instance
* can mount the file system as on AWS. Needs VPC networks ({@code floci.services.ec2.vpc-networks}).
*/
@WithDefault("true")
boolean nfsServers();
/** Image of the NFS server; the default is built locally from Floci's own recipe on first use. */
@WithDefault("floci/efs-nfs-server:local")
String nfsServerImage();
}
interface LightsailServiceConfig {
@@ -1345,6 +1366,19 @@ public interface EmulatorConfig {
int kafkaHostPortMax();
}
interface KafkaConnectServiceConfig {
@WithDefault("true")
boolean enabled();
/** Metadata-only mode is useful where Docker is intentionally unavailable. */
@WithDefault("false")
boolean mock();
/** Stock Apache Kafka distribution, never a connector-plugin image. */
@WithDefault("apache/kafka:3.9.0")
String workerImage();
}
interface AmazonMqServiceConfig {
@WithDefault("true")
boolean enabled();
@@ -2799,6 +2833,10 @@ public interface EmulatorConfig {
@WithDefault("false")
boolean mock();
/** Exact image used by the Docker-host listener forwarding sidecar. */
@WithDefault("alpine/socat:1.8.1.3")
String listenerForwarderImage();
}
interface EksServiceConfig {
@@ -146,6 +146,12 @@ public class ResolvedServiceCatalog {
"msk", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
Set.of(), Set.of("kafka"), Set.of(), Set.of(io.github.hectorvent.floci.services.msk.MskController.class)),
descriptor("kafkaconnect", "kafkaconnect", config.services().kafkaconnect().enabled(), true,
"kafkaconnect", storageMode(config.storage().services().kafkaconnect().mode(), config.storage().mode()),
config.storage().services().kafkaconnect().flushIntervalMs(), null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
Set.of(), Set.of("kafkaconnect"), Set.of(),
Set.of(io.github.hectorvent.floci.services.kafkaconnect.KafkaConnectController.class)),
descriptor("mq", "amazonmq", config.services().amazonmq().enabled(), true,
"amazonmq", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON),
@@ -0,0 +1,97 @@
package io.github.hectorvent.floci.core.common;
import java.util.List;
/**
* The message AWS's Smithy validation layer answers when a request breaks its model's constraints:
* "1 validation error detected: a" or "N validation errors detected: a; b", each violation reading
* "Value at 'path' failed to satisfy constraint: ...". The wording is Smithy's, shared by every
* service built on it; only the error code differs by protocol.
*/
public final class SmithyValidationErrors {
private SmithyValidationErrors() {}
/**
* The message for one or more violations. An empty list is the caller's bug, so it fails rather
* than answering "0 validation errors"; check {@code isEmpty()} before throwing.
*/
public static String message(List<String> violations) {
if (violations.isEmpty()) {
throw new IllegalArgumentException("no violations to report");
}
String count = violations.size() == 1
? "1 validation error detected: "
: violations.size() + " validation errors detected: ";
return count + String.join("; ", violations);
}
/** The violation for a required member the request leaves out. */
public static String notNull(String path) {
return "Value null at '" + path + "' failed to satisfy constraint: Member must not be null";
}
/** The 400 error carrying {@link #message} under the protocol's validation error code. */
public static AwsException exception(String errorCode, List<String> violations) {
return new AwsException(errorCode, message(violations), 400);
}
public static AwsException exception(String errorCode, String violation) {
return exception(errorCode, List.of(violation));
}
/** The code most JSON services use, on both the awsJson and the restJson protocols. */
public static AwsException validationException(List<String> violations) {
return exception("ValidationException", violations);
}
public static AwsException validationException(String violation) {
return validationException(List.of(violation));
}
/** The awsQuery protocol's code. */
public static AwsException validationError(List<String> violations) {
return exception("ValidationError", violations);
}
public static AwsException validationError(String violation) {
return validationError(List.of(violation));
}
/** The code of REST JSON services that model their validation failure as BadRequestException. */
public static AwsException badRequest(List<String> violations) {
return exception("BadRequestException", violations);
}
public static AwsException badRequest(String violation) {
return badRequest(List.of(violation));
}
/** The violation for a number outside {@code min} to {@code max}, or {@code null} for one inside. */
public static String outOfRange(String path, long value, long min, long max) {
if (value < min) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must have value greater than or equal to " + min;
}
if (value > max) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must have value less than or equal to " + max;
}
return null;
}
/** The violation for a value outside its enum, listing the allowed values in the model's order. */
public static String notInEnum(String value, String path, List<String> allowed) {
return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must satisfy enum value set: " + allowed;
}
/**
* The violation for a list holding a value outside its members' enum. The list is checked as a
* whole, so the whole list is the offending value and the enum constraint is nested.
*/
public static String notAllInEnum(List<String> values, String path, List<String> allowed) {
return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: "
+ "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]";
}
}
@@ -68,6 +68,14 @@ public class EmbeddedDnsServer {
// localhost.floci.io → localhost.floci.io, *.localhost.floci.io
public static final List<String> BUILTIN_SUFFIXES = List.of(DEFAULT_SUFFIX, LOCALSTACK_SUFFIX);
/** Answers A queries for names a service owns, such as an EFS file system's DNS name. */
@FunctionalInterface
public interface NameResolver {
Optional<String> resolve(String name);
}
private final List<NameResolver> resolvers = new java.util.concurrent.CopyOnWriteArrayList<>();
private volatile String serverIp;
private final SequencedSet<String> suffixes = new LinkedHashSet<>();
private volatile List<String> upstreamDnsServers = List.of();
@@ -163,11 +171,30 @@ public class EmbeddedDnsServer {
return false;
}
/** Adds names a service answers for; a name no resolver knows is forwarded upstream as before. */
public void addResolver(NameResolver resolver) {
resolvers.add(resolver);
}
Optional<String> resolveARecord(String name, String myIp) {
if (matchesSuffix(name)) {
return Optional.of(myIp);
}
return resolveEc2PrivateDnsName(name);
Optional<String> ec2Name = resolveEc2PrivateDnsName(name);
if (ec2Name.isPresent()) {
return ec2Name;
}
for (NameResolver resolver : resolvers) {
try {
Optional<String> address = resolver.resolve(name);
if (address.isPresent()) {
return address;
}
} catch (RuntimeException e) {
LOG.debugv("DNS resolver failed for {0}: {1}", name, e.getMessage());
}
}
return Optional.empty();
}
Optional<String> resolveEc2PrivateDnsName(String name) {
@@ -362,6 +362,20 @@ public class ContainerBuilder {
return this;
}
/**
* Adds a named volume mount of one directory inside the volume, given relative to its root.
* The directory must already exist; Docker refuses to start the container otherwise.
*/
public Builder withNamedVolumeSubpath(String volumeName, String subpath, String containerPath, boolean readOnly) {
this.mounts.add(new Mount()
.withType(MountType.VOLUME)
.withSource(volumeName)
.withTarget(containerPath)
.withReadOnly(readOnly)
.withVolumeOptions(new SubpathVolumeOptions(subpath)));
return this;
}
/**
* Inherits every volume declared by another container.
*/
@@ -34,6 +34,7 @@ import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -110,6 +111,10 @@ public class ContainerLifecycleManager {
}
}
public String refreshImage(String image) {
return imageCacheService.refreshImage(image);
}
/**
* Creates a container without starting it. Use {@link #startCreated} to
* start it after any pre-start setup (e.g. copying files into the
@@ -120,7 +125,7 @@ public class ContainerLifecycleManager {
*/
public String create(ContainerSpec spec) {
String resolvedImage = imageCacheService.ensureImageExists(spec.image());
return create(spec, resolvedImage, null);
return create(spec, resolvedImage, (String) null);
}
/**
@@ -135,6 +140,35 @@ public class ContainerLifecycleManager {
return create(spec, resolvedImage, platform);
}
public String create(ContainerSpec spec, String platform, ContainerCreationObserver observer) {
String resolvedImage = imageCacheService.ensureImageExists(spec.image(), platform,
new ImageCacheService.ImagePullObserver() {
@Override
public void started(String imageUri) {
observer.imagePullStarted(imageUri);
}
@Override
public void completed(String imageUri) {
observer.imagePullCompleted(imageUri);
}
});
observer.containerCreationStarted();
String containerId = create(spec, resolvedImage, platform);
observer.containerCreated(containerId);
return containerId;
}
public interface ContainerCreationObserver {
default void imagePullStarted(String imageUri) {}
default void imagePullCompleted(String imageUri) {}
default void containerCreationStarted() {}
default void containerCreated(String containerId) {}
}
private String create(ContainerSpec spec, String resolvedImage, String platform) {
String containerId = createWithCaBundle(spec, resolvedImage, platform);
if (spec.hasNetworkConfiguration()) {
@@ -555,6 +589,77 @@ public class ContainerLifecycleManager {
rootPermissions.ifPresent(p -> script.append("chmod ").append(p).append(' ').append(mount).append(" && "));
script.append("true");
Integer status = runVolumeHelper(volumeName, mount, initImage, List.of("sh", "-c", script.toString()));
if (status == null || status != 0) {
// Throw so the caller leaves the volume unmemoised and retries on the next launch,
// rather than leaving it root:root 0755 with no further attempt.
throw new IllegalStateException("shared-volume init for " + volumeName
+ " exited with status " + status + " (cmd: " + script + ")");
}
LOG.infov("Initialised shared volume {0} root (cmd: {1})", volumeName, script);
}
/** Owner and permissions for a directory created inside a shared volume (EFS {@code CreationInfo}). */
public record DirectoryCreation(long ownerUid, long ownerGid, String permissions) {
public DirectoryCreation {
if (permissions == null || !permissions.matches("^[0-7]{3,4}$")) {
throw new IllegalArgumentException("permissions must be 3-4 octal digits: " + permissions);
}
}
}
// Walks the path one directory at a time so that every directory it creates, not only the
// last, gets the owner and permissions, as EFS does for an access point's root directory.
private static final String ENSURE_DIRECTORY_SCRIPT = """
set -f
cd /floci-shared-volume || exit 2
[ -d "$1" ] && exit 0
[ "$#" -eq 4 ] || exit 3
p=
IFS=/
for part in $1; do
[ -n "$part" ] || continue
p="${p:+$p/}$part"
if [ ! -d "$p" ]; then
mkdir "$p" && chown "$2:$3" "$p" && chmod "$4" "$p" || exit 1
fi
done
""";
/**
* Ensures {@code relativePath} exists inside the named volume, emulating how Amazon EFS creates
* an access point's root directory on first use: each missing directory on the path is created
* with {@code creation}'s owner and permissions. When the directory is missing and
* {@code creation} is empty nothing is created and this returns false, since EFS then refuses
* the mount. The volume itself must already exist.
*/
public boolean ensureSharedVolumeDirectory(String volumeName, String relativePath,
Optional<DirectoryCreation> creation, String initImage) {
if (relativePath == null || relativePath.isBlank() || relativePath.startsWith("/")
|| List.of(relativePath.split("/")).contains("..")) {
throw new IllegalArgumentException("directory must be relative to the volume root: " + relativePath);
}
String memoKey = volumeName + "/" + relativePath;
if (initializedSharedVolumes.containsKey(memoKey)) {
return true;
}
List<String> cmd = new ArrayList<>(List.of("sh", "-c", ENSURE_DIRECTORY_SCRIPT, "sh", relativePath));
creation.ifPresent(c -> cmd.addAll(List.of(
Long.toString(c.ownerUid()), Long.toString(c.ownerGid()), c.permissions())));
Integer status = runVolumeHelper(volumeName, "/floci-shared-volume", initImage, cmd);
if (status != null && status == 3) {
return false;
}
if (status == null || status != 0) {
throw new IllegalStateException("creating " + relativePath + " in volume " + volumeName
+ " exited with status " + status);
}
initializedSharedVolumes.put(memoKey, Boolean.TRUE);
return true;
}
/** Runs a one-off helper container with the volume mounted at {@code mount}; returns its exit status. */
private Integer runVolumeHelper(String volumeName, String mount, String initImage, List<String> cmd) {
String image = (initImage != null && !initImage.isBlank()) ? initImage : "busybox:stable";
imageCacheService.ensureImageExists(image);
@@ -562,22 +667,15 @@ public class ContainerLifecycleManager {
new Mount().withType(MountType.VOLUME).withSource(volumeName).withTarget(mount)));
CreateContainerResponse created = dockerClient.createContainerCmd(image)
.withHostConfig(hostConfig)
.withCmd("sh", "-c", script.toString())
.withCmd(cmd.toArray(String[]::new))
.withLabels(ContainerStorageHelper.defaultLabels(config))
.exec();
String helperId = created.getId();
try {
startContainer(helperId);
Integer status = dockerClient.waitContainerCmd(helperId)
return dockerClient.waitContainerCmd(helperId)
.exec(new WaitContainerResultCallback())
.awaitStatusCode(60, TimeUnit.SECONDS);
if (status == null || status != 0) {
// Throw so the caller leaves the volume unmemoised and retries on the next launch,
// rather than leaving it root:root 0755 with no further attempt.
throw new IllegalStateException("shared-volume init for " + volumeName
+ " exited with status " + status + " (cmd: " + script + ")");
}
LOG.infov("Initialised shared volume {0} root (cmd: {1})", volumeName, script);
} finally {
try {
dockerClient.removeContainerCmd(helperId).withForce(true).exec();
@@ -5,6 +5,7 @@ import com.github.dockerjava.api.command.InspectContainerResponse;
import com.github.dockerjava.api.model.ContainerNetwork;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.Ports;
import io.github.hectorvent.floci.config.EmulatorConfig;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
@@ -32,14 +33,21 @@ public class CurrentContainerNetworkResolver {
private final DockerClient dockerClient;
private final ContainerDetector containerDetector;
private final EmulatorConfig config;
private final Map<Integer, Integer> cachedPublishedPorts = new ConcurrentHashMap<>();
private volatile Optional<CurrentContainerNetwork> cachedNetwork;
@Inject
public CurrentContainerNetworkResolver(DockerClient dockerClient, ContainerDetector containerDetector) {
public CurrentContainerNetworkResolver(DockerClient dockerClient, ContainerDetector containerDetector,
EmulatorConfig config) {
this.dockerClient = dockerClient;
this.containerDetector = containerDetector;
this.config = config;
}
public CurrentContainerNetworkResolver(DockerClient dockerClient, ContainerDetector containerDetector) {
this(dockerClient, containerDetector, null);
}
public Optional<String> resolveNetworkName() {
@@ -148,11 +156,21 @@ public class CurrentContainerNetworkResolver {
}
}
/**
* Spawned containers join services.docker-network when it is set, so Floci's address on that
* network is the one they can reach. Floci can be attached to other networks too (for example
* the emulated VPC networks), whose addresses spawned containers cannot route to.
*/
private Optional<CurrentContainerNetwork> selectNetwork(Map<String, ContainerNetwork> networks) {
return networks.entrySet().stream()
.filter(entry -> isUsable(entry.getValue()))
.filter(entry -> isUserDefinedNetwork(entry.getKey()))
.findFirst()
Optional<String> configured = config == null ? Optional.empty()
: config.services().dockerNetwork().filter(name -> !name.isBlank());
return configured
.filter(name -> isUsable(networks.get(name)))
.map(name -> Map.entry(name, networks.get(name)))
.or(() -> networks.entrySet().stream()
.filter(entry -> isUsable(entry.getValue()))
.filter(entry -> isUserDefinedNetwork(entry.getKey()))
.findFirst())
.or(() -> networks.entrySet().stream()
.filter(entry -> isUsable(entry.getValue()))
.findFirst())
@@ -13,6 +13,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.function.IntConsumer;
/**
* Utility for allocating free TCP ports for Docker container port bindings.
@@ -55,6 +56,10 @@ public class PortAllocator {
* @throws RuntimeException if no free port is available in the range
*/
public synchronized int allocate(int basePort, int maxPort) {
return allocate(basePort, maxPort, ignored -> {});
}
public synchronized int allocate(int basePort, int maxPort, IntConsumer occupiedPortObserver) {
// One Docker API round-trip per allocation, not one per candidate: the
// snapshot is only as fresh as the moment allocate() started either way,
// and this loop runs inside a lock every consumer of the shared
@@ -66,6 +71,7 @@ public class PortAllocator {
LOG.debugv("Allocated port {0} from range {1}-{2}", String.valueOf(port), String.valueOf(basePort), String.valueOf(maxPort));
return port;
}
occupiedPortObserver.accept(port);
}
throw new RuntimeException("No free port available in range " + basePort + "-" + maxPort);
}
@@ -0,0 +1,26 @@
package io.github.hectorvent.floci.core.common.docker;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.dockerjava.api.model.VolumeOptions;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* {@link VolumeOptions} with the {@code Subpath} field (Docker Engine API 1.45+), which mounts one
* directory of a named volume instead of its root. docker-java 3.7 does not model the field yet.
*/
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SubpathVolumeOptions extends VolumeOptions {
@JsonProperty("Subpath")
private final String subpath;
public SubpathVolumeOptions(String subpath) {
this.subpath = subpath;
}
public String getSubpath() {
return subpath;
}
}
@@ -6,13 +6,20 @@ import io.github.hectorvent.floci.config.FlociCertificateAuthority;
import io.github.hectorvent.floci.core.common.ContainerTeardown;
import io.github.hectorvent.floci.core.common.ContainerTeardowns;
import io.github.hectorvent.floci.core.common.ServiceRegistry;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.lightsail.LightsailService;
import io.github.hectorvent.floci.services.lightsail.LightsailContainerManager;
import io.github.hectorvent.floci.lifecycle.inithook.InitializationHook;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import org.jboss.logging.Logger;
import io.github.hectorvent.floci.core.storage.StorageFactory;
@@ -44,15 +51,18 @@ public class EmulatorInfoController {
private final Instance<ContainerTeardown> containerTeardowns;
private final FlociCertificateAuthority certificateAuthority;
private final EmulatorConfig config;
private final Ec2Service ec2Service;
private final LightsailService lightsailService;
@Inject
public EmulatorInfoController(ServiceRegistry serviceRegistry,
InitLifecycleState initLifecycleState,
StorageFactory storageFactory,
Instance<Resettable> resettables,
Instance<ContainerTeardown> containerTeardowns,
FlociCertificateAuthority certificateAuthority,
EmulatorConfig config) {
Instance<ContainerTeardown> containerTeardowns,
FlociCertificateAuthority certificateAuthority,
EmulatorConfig config,
Ec2Service ec2Service, LightsailService lightsailService) {
this.serviceRegistry = serviceRegistry;
this.initLifecycleState = initLifecycleState;
this.storageFactory = storageFactory;
@@ -60,6 +70,8 @@ public class EmulatorInfoController {
this.containerTeardowns = containerTeardowns;
this.certificateAuthority = certificateAuthority;
this.config = config;
this.ec2Service = ec2Service;
this.lightsailService = lightsailService;
this.version = resolveVersion();
}
@@ -104,7 +116,41 @@ public class EmulatorInfoController {
@GET
@Path("/diagnose")
public Response diagnose() {
return Response.ok(Map.of()).build();
return Response.ok(Map.of("ec2", Map.of("instances", ec2Service.diagnosticInstances()))).build();
}
@GET
@Path("/diagnostics/lightsail/instances/{instanceName}/host-ports")
public Response lightsailHostPorts(@PathParam("instanceName") String instanceName) {
return Response.ok(Map.of("mappings", lightsailService.hostPortMappings(config.defaultRegion(), instanceName))).build();
}
@POST
@Path("/diagnostics/lightsail/instances/{instanceName}/static-files")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response deployLightsailStaticFiles(@PathParam("instanceName") String instanceName,
@RestForm("files") List<FileUpload> files) {
List<LightsailContainerManager.StaticFile> staticFiles = files == null ? List.of() : files.stream()
.map(file -> new LightsailContainerManager.StaticFile(file.fileName(), file.uploadedFile()))
.toList();
int deployed = lightsailService.deployStaticFiles(config.defaultRegion(), instanceName, staticFiles);
return Response.ok(Map.of("status", "deployed", "files", deployed)).build();
}
@POST
@Path("/diagnostics/lightsail/instances/{instanceName}/node-application")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response deployLightsailNodeApplication(@PathParam("instanceName") String instanceName,
@RestForm("files") List<FileUpload> files,
@RestForm("startCommand") String startCommand,
@RestForm("port") Integer port) {
List<LightsailContainerManager.StaticFile> applicationFiles = files == null ? List.of() : files.stream()
.map(file -> new LightsailContainerManager.StaticFile(file.fileName(), file.uploadedFile()))
.toList();
LightsailContainerManager.NodeDeployment deployment = lightsailService.deployNodeApplication(
config.defaultRegion(), instanceName, applicationFiles, startCommand, port == null ? 3000 : port);
return Response.ok(Map.of("status", "deployed", "files", deployment.files(), "port", deployment.port(),
"releaseId", deployment.releaseId())).build();
}
@GET
@@ -563,8 +563,10 @@ public class AcmJsonHandler {
private CertificateOptions parseOptions(JsonNode optionsNode) {
if (optionsNode.isMissingNode()) return null;
String ctPref = optionsNode.path("CertificateTransparencyLoggingPreference").asText("ENABLED");
return new CertificateOptions(ctPref, "DISABLED");
String ctPref = parseOptionValue(optionsNode, "CertificateTransparencyLoggingPreference");
String export = parseOptionValue(optionsNode, "Export");
// An exportable public certificate is requested with Export ENABLED, and keeps it for life.
return new CertificateOptions(ctPref != null ? ctPref : "ENABLED", export != null ? export : "DISABLED");
}
private List<CertificateStatus> parseCertificateStatuses(JsonNode node) {
@@ -43,6 +43,7 @@ import io.github.hectorvent.floci.services.apigatewayv2.model.Route;
import io.github.hectorvent.floci.services.apigatewayv2.model.RouteResponse;
import io.github.hectorvent.floci.services.apigatewayv2.model.Stage;
import io.github.hectorvent.floci.services.apigatewayv2.model.VpcLink;
import io.smallrye.common.annotation.Blocking;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -72,16 +73,25 @@ public class ApiGatewayController {
private final ApiGatewayV2OpenApiImporter v2OpenApiImporter;
private final RegionResolver regionResolver;
private final ObjectMapper objectMapper;
private final ApiGatewayExecuteController executeController;
@Inject
public ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service,
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper) {
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper,
ApiGatewayExecuteController executeController) {
this.service = service;
this.v2Service = v2Service;
this.v2OpenApiImporter = v2OpenApiImporter;
this.regionResolver = regionResolver;
this.objectMapper = objectMapper;
this.executeController = executeController;
}
ApiGatewayController(ApiGatewayService service, ApiGatewayV2Service v2Service,
ApiGatewayV2OpenApiImporter v2OpenApiImporter,
RegionResolver regionResolver, ObjectMapper objectMapper) {
this(service, v2Service, v2OpenApiImporter, regionResolver, objectMapper, null);
}
private static final TypeReference<List<Map<String, String>>> PATCH_OPERATIONS =
@@ -129,6 +139,81 @@ public class ApiGatewayController {
// ──────────────────────────── Specific v1 Paths (ORDER MATTERS) ────────────────────────────
@POST
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}")
@Blocking
public Response testInvokeMethod(@Context HttpHeaders headers,
@PathParam("apiId") String apiId,
@PathParam("resourceId") String resourceId,
@PathParam("httpMethod") String httpMethod,
String body) {
String region = regionResolver.resolveRegion(headers);
try {
@SuppressWarnings("unchecked")
Map<String, Object> request = objectMapper.readValue(body == null ? "{}" : body, Map.class);
ApiGatewayExecuteController.TestInvokeRequest testRequest =
new ApiGatewayExecuteController.TestInvokeRequest(
(String) request.get("pathWithQueryString"),
(String) request.get("body"),
stringMap(request.get("headers")),
stringListMap(request.get("multiValueHeaders")),
stringMap(request.get("stageVariables")));
ApiGatewayExecuteController.TestInvokeResult result =
executeController.testInvoke(region, apiId, resourceId, httpMethod, testRequest);
return Response.ok(toTestInvokeNode(result).toString()).type(MediaType.APPLICATION_JSON).build();
} catch (IOException | ClassCastException e) {
throw new AwsException("BadRequestException", e.getMessage(), 400);
}
}
private Map<String, String> stringMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, String> result = new HashMap<>();
map.forEach((key, entryValue) -> {
if (key instanceof String stringKey && entryValue instanceof String stringValue) {
result.put(stringKey, stringValue);
}
});
return result;
}
private Map<String, List<String>> stringListMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, List<String>> result = new HashMap<>();
map.forEach((key, entryValue) -> {
if (key instanceof String stringKey && entryValue instanceof List<?> list) {
result.put(stringKey, list.stream().filter(String.class::isInstance).map(String.class::cast).toList());
}
});
return result;
}
private ObjectNode toTestInvokeNode(ApiGatewayExecuteController.TestInvokeResult result) {
Response executionResponse = result.response();
ObjectNode node = objectMapper.createObjectNode();
node.put("status", executionResponse.getStatus());
Object entity = executionResponse.getEntity();
if (entity != null) {
node.put("body", entity instanceof byte[] bytes ? new String(bytes, StandardCharsets.UTF_8) : entity.toString());
}
ObjectNode responseHeaders = node.putObject("headers");
ObjectNode multiValueResponseHeaders = node.putObject("multiValueHeaders");
executionResponse.getStringHeaders().forEach((name, values) -> {
if (!values.isEmpty()) {
responseHeaders.put(name, values.get(values.size() - 1));
ArrayNode multiValues = multiValueResponseHeaders.putArray(name);
values.forEach(multiValues::add);
}
});
node.put("log", result.log());
node.put("latency", result.latency());
return node;
}
@GET
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response getMethodResponse(@Context HttpHeaders headers,
@@ -159,6 +244,21 @@ public class ApiGatewayController {
}
}
@PATCH
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response updateMethodResponse(@Context HttpHeaders headers,
@PathParam("apiId") String apiId,
@PathParam("resourceId") String resourceId,
@PathParam("httpMethod") String httpMethod,
@PathParam("statusCode") String statusCode,
String body) {
String region = regionResolver.resolveRegion(headers);
List<Map<String, String>> patchOperations = parsePatchOperations(body);
MethodResponse response = service.updateMethodResponse(region, apiId, resourceId, httpMethod,
statusCode, patchOperations);
return Response.ok(toMethodResponseNode(response).toString()).type(MediaType.APPLICATION_JSON).build();
}
@DELETE
@Path("/restapis/{apiId}/resources/{resourceId}/methods/{httpMethod}/responses/{statusCode}")
public Response deleteMethodResponse(@Context HttpHeaders headers,
@@ -387,12 +487,14 @@ public class ApiGatewayController {
@GET
@Path("/restapis/{apiId}/resources")
public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId) {
public Response getResources(@Context HttpHeaders headers, @PathParam("apiId") String apiId,
@QueryParam("embed") List<String> embed) {
String region = regionResolver.resolveRegion(headers);
List<ApiGatewayResource> resources = service.getResources(region, apiId);
boolean embedMethods = embed != null && embed.contains("methods");
ObjectNode root = objectMapper.createObjectNode();
ArrayNode items = root.putArray("item");
resources.forEach(r -> items.add(toResourceNode(r)));
resources.forEach(r -> items.add(toResourceNode(r, embedMethods)));
return Response.ok(root.toString()).type(MediaType.APPLICATION_JSON).build();
}
@@ -2162,11 +2264,19 @@ public class ApiGatewayController {
}
private ObjectNode toResourceNode(ApiGatewayResource r) {
return toResourceNode(r, false);
}
private ObjectNode toResourceNode(ApiGatewayResource r, boolean embedMethods) {
ObjectNode node = objectMapper.createObjectNode();
node.put("id", r.getId());
if (r.getParentId() != null) node.put("parentId", r.getParentId());
if (r.getPathPart() != null) node.put("pathPart", r.getPathPart());
node.put("path", r.getPath());
if (embedMethods && !r.getResourceMethods().isEmpty()) {
ObjectNode methods = node.putObject("resourceMethods");
r.getResourceMethods().forEach((method, config) -> methods.set(method, toMethodNode(config)));
}
return node;
}
@@ -2186,6 +2296,11 @@ public class ApiGatewayController {
m.getRequestModels().forEach(models::put);
node.set("requestModels", models);
}
if (!m.getMethodResponses().isEmpty()) {
ObjectNode responses = node.putObject("methodResponses");
m.getMethodResponses().forEach((status, response) ->
responses.set(status, toMethodResponseNode(response)));
}
if (m.getMethodIntegration() != null) {
node.set("methodIntegration", toIntegrationNode(m.getMethodIntegration()));
}
@@ -2195,6 +2310,14 @@ public class ApiGatewayController {
private ObjectNode toMethodResponseNode(MethodResponse r) {
ObjectNode node = objectMapper.createObjectNode();
node.put("statusCode", r.statusCode());
if (r.responseParameters() != null && !r.responseParameters().isEmpty()) {
ObjectNode parameters = node.putObject("responseParameters");
r.responseParameters().forEach(parameters::put);
}
if (r.responseModels() != null && !r.responseModels().isEmpty()) {
ObjectNode models = node.putObject("responseModels");
r.responseModels().forEach(models::put);
}
return node;
}
@@ -43,6 +43,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.URLDecoder;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -54,6 +55,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -144,6 +146,12 @@ public class ApiGatewayExecuteController {
private record AuthorizerResult(Response errorResponse, String principalId, Map<String, Object> context) {}
record TestInvokeRequest(String pathWithQueryString, String body, Map<String, String> headers,
Map<String, List<String>> multiValueHeaders,
Map<String, String> stageVariables) {}
record TestInvokeResult(Response response, String log, long latency) {}
/**
* What a gateway-generated error needs in order to render the REST API's gateway response for
* it: where the request landed and the raw request, for {@code method.request.*},
@@ -471,6 +479,85 @@ public class ApiGatewayExecuteController {
return response;
}
/**
* Executes a REST API method without requiring a deployment or stage. This is the execution
* path behind TestInvokeMethod, not an alternate data-plane endpoint.
*/
TestInvokeResult testInvoke(String region, String apiId, String resourceId, String httpMethod,
TestInvokeRequest request) {
ApiGatewayResource resource = apiGatewayService.getResource(region, apiId, resourceId);
MethodConfig method = apiGatewayService.getMethod(region, apiId, resourceId, httpMethod);
Integration integration = method.getMethodIntegration();
if (integration == null) {
Response response = Response.status(500).entity("No integration configured").build();
return new TestInvokeResult(response, "Execution failed due to configuration error: No integration configured", 0);
}
String pathWithQueryString = request.pathWithQueryString() == null || request.pathWithQueryString().isBlank()
? resource.getPath() : request.pathWithQueryString();
URI requestUri = URI.create("http://test-invoke" + (pathWithQueryString.startsWith("/")
? pathWithQueryString : "/" + pathWithQueryString));
String path = requestUri.getRawPath();
HttpHeaders headers = new TestInvokeHeaders(request.headers(), request.multiValueHeaders());
UriInfo uriInfo = new TestInvokeUriInfo(requestUri);
Stage stage = new Stage();
stage.setStageName("test-invoke-stage");
stage.setVariables(request.stageVariables());
byte[] body = request.body() == null ? null : request.body().getBytes(StandardCharsets.UTF_8);
GatewayResponseScope scope = new GatewayResponseScope(region, apiId, stage.getStageName(), stage,
httpMethod, path, resource, headers, uriInfo, body);
String integrationType = integration.getType();
if (integrationType == null || integrationType.isBlank()) {
Response response = Response.status(500).entity("No integration type configured").build();
return new TestInvokeResult(response,
"Execution failed due to configuration error: No integration type configured", 0);
}
long started = System.nanoTime();
Response response;
String log;
if ("AWS_PROXY".equalsIgnoreCase(integrationType)) {
String functionName = functionNameFromUri(integration.getUri());
if (functionName == null) {
response = Response.status(500).entity("Cannot resolve function from URI: " + integration.getUri()).build();
log = "Execution failed due to configuration error: Cannot resolve function from URI: "
+ integration.getUri();
} else {
String eventJson = buildProxyEvent(region, apiId, httpMethod, path, resource.getPath(), resource.getId(),
stage.getStageName(), stage, headers, uriInfo, body, UUID.randomUUID().toString(),
null, null, null, null);
log = "Endpoint request URI: " + integration.getUri()
+ "\nEndpoint request body after transformations: " + eventJson;
InvokeResult result = lambdaService.invoke(region, functionName, eventJson.getBytes(StandardCharsets.UTF_8),
InvocationType.RequestResponse);
if (isGatewayGeneratedProxyFailure(result)) {
response = Response.status(500)
.entity("Execution failed due to configuration error: Malformed Lambda proxy response")
.build();
log += "\nExecution failed due to configuration error: Malformed Lambda proxy response";
} else {
response = buildProxyResponse(result, false);
}
}
} else {
AuthorizerResult authorizerResult = new AuthorizerResult(null, null, null);
response = switch (integrationType.toUpperCase(Locale.ROOT)) {
case "AWS" -> invokeAwsIntegration(scope, region, httpMethod, path, stage.getStageName(), resource,
integration, headers, uriInfo, body, authorizerResult);
case "HTTP_PROXY" -> invokeHttpProxy(scope, apiId, httpMethod, path, null, stage.getStageName(), resource,
integration, headers, uriInfo, body);
case "HTTP" -> invokeHttpIntegration(scope, region, apiId, httpMethod, path, null, stage.getStageName(),
resource, integration, headers, uriInfo, body, authorizerResult);
case "MOCK" -> invokeMock(scope, region, httpMethod, path, stage.getStageName(), resource, integration,
headers, uriInfo, body, authorizerResult);
default -> Response.status(500).entity("Unsupported integration type: " + integration.getType()).build();
};
log = "Endpoint request URI: " + integration.getUri();
}
long latency = (System.nanoTime() - started) / 1_000_000L;
return new TestInvokeResult(response, log + "\nLatency: " + latency + " ms", latency);
}
private void applyApiOwnerContext(ApiGatewayV2Service.ApiOwner owner) {
requestContext.setAccountId(owner.accountId());
requestContext.setRegion(owner.region());
@@ -952,7 +1039,8 @@ public class ApiGatewayExecuteController {
return false;
}
try {
return !objectMapper.readTree(payload).isObject();
JsonNode response = objectMapper.readTree(payload);
return !response.isObject() || !response.has("statusCode");
} catch (IOException e) {
return true;
}
@@ -1413,6 +1501,9 @@ public class ApiGatewayExecuteController {
}
try {
JsonNode node = objectMapper.readTree(result.getPayload());
if (!node.isObject() || !node.has("statusCode")) {
return Response.status(502).build();
}
int statusCode = node.path("statusCode").asInt(200);
if (result.getFunctionError() != null && !node.has("statusCode")) statusCode = 502;
@@ -1475,6 +1566,191 @@ public class ApiGatewayExecuteController {
return Optional.empty();
}
private static final class TestInvokeHeaders implements HttpHeaders {
private final MultivaluedMap<String, String> headers = new MultivaluedHashMap<>();
private TestInvokeHeaders(Map<String, String> singleValueHeaders,
Map<String, List<String>> multiValueHeaders) {
if (singleValueHeaders != null) {
singleValueHeaders.forEach(headers::putSingle);
}
if (multiValueHeaders != null) {
multiValueHeaders.forEach((name, values) -> headers.put(name,
values == null ? new ArrayList<>() : new ArrayList<>(values)));
}
}
@Override
public List<String> getRequestHeader(String name) {
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (entry.getKey().equalsIgnoreCase(name)) {
return entry.getValue();
}
}
return List.of();
}
@Override
public String getHeaderString(String name) {
List<String> values = getRequestHeader(name);
return values == null || values.isEmpty() ? null : String.join(",", values);
}
@Override
public MultivaluedMap<String, String> getRequestHeaders() {
return headers;
}
@Override
public List<MediaType> getAcceptableMediaTypes() {
return List.of();
}
@Override
public List<Locale> getAcceptableLanguages() {
return List.of();
}
@Override
public MediaType getMediaType() {
return null;
}
@Override
public Locale getLanguage() {
return null;
}
@Override
public Map<String, Cookie> getCookies() {
return Map.of();
}
@Override
public Date getDate() {
return null;
}
@Override
public int getLength() {
return -1;
}
}
private static final class TestInvokeUriInfo implements UriInfo {
private final URI requestUri;
private final MultivaluedMap<String, String> queryParameters = new MultivaluedHashMap<>();
private TestInvokeUriInfo(URI requestUri) {
this.requestUri = requestUri;
String rawQuery = requestUri.getRawQuery();
if (rawQuery == null || rawQuery.isEmpty()) {
return;
}
for (String part : rawQuery.split("&")) {
String[] keyValue = part.split("=", 2);
String key = URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8);
String value = keyValue.length == 2 ? URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8) : "";
queryParameters.add(key, value);
}
}
@Override
public String getPath() {
return requestUri.getPath();
}
@Override
public String getPath(boolean decode) {
return decode ? requestUri.getPath() : requestUri.getRawPath();
}
@Override
public List<PathSegment> getPathSegments() {
return List.of();
}
@Override
public List<PathSegment> getPathSegments(boolean decode) {
return List.of();
}
@Override
public URI getRequestUri() {
return requestUri;
}
@Override
public UriBuilder getRequestUriBuilder() {
return UriBuilder.fromUri(requestUri);
}
@Override
public URI getAbsolutePath() {
return UriBuilder.fromUri(requestUri).replaceQuery(null).build();
}
@Override
public UriBuilder getAbsolutePathBuilder() {
return UriBuilder.fromUri(getAbsolutePath());
}
@Override
public URI getBaseUri() {
return URI.create("http://test-invoke/");
}
@Override
public UriBuilder getBaseUriBuilder() {
return UriBuilder.fromUri(getBaseUri());
}
@Override
public MultivaluedMap<String, String> getPathParameters() {
return new MultivaluedHashMap<>();
}
@Override
public MultivaluedMap<String, String> getPathParameters(boolean decode) {
return getPathParameters();
}
@Override
public MultivaluedMap<String, String> getQueryParameters() {
return queryParameters;
}
@Override
public MultivaluedMap<String, String> getQueryParameters(boolean decode) {
return queryParameters;
}
@Override
public List<String> getMatchedURIs() {
return List.of();
}
@Override
public List<String> getMatchedURIs(boolean decode) {
return List.of();
}
@Override
public List<Object> getMatchedResources() {
return List.of();
}
@Override
public URI resolve(URI uri) {
return getBaseUri().resolve(uri);
}
@Override
public URI relativize(URI uri) {
return getBaseUri().relativize(uri);
}
}
// ──────────────────────────── AWS (non-proxy) ────────────────────────────
private MultivaluedMap<String, String> parseFormEncodedBody(String body) {
@@ -496,10 +496,16 @@ public class ApiGatewayService {
}
public MethodResponse putMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode,
Map<String, Object> request) {
String httpMethod, String statusCode,
Map<String, Object> request) {
MethodConfig method = getMethod(region, apiId, resourceId, httpMethod);
MethodResponse mr = new MethodResponse(statusCode, new HashMap<>());
@SuppressWarnings("unchecked")
Map<String, Boolean> responseParameters = (Map<String, Boolean>) request.get("responseParameters");
@SuppressWarnings("unchecked")
Map<String, String> responseModels = (Map<String, String>) request.get("responseModels");
MethodResponse mr = new MethodResponse(statusCode,
responseParameters != null ? new HashMap<>(responseParameters) : new HashMap<>(),
responseModels != null ? new HashMap<>(responseModels) : new HashMap<>());
method.getMethodResponses().put(statusCode, mr);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return mr;
@@ -524,6 +530,49 @@ public class ApiGatewayService {
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
}
public MethodResponse updateMethodResponse(String region, String apiId, String resourceId,
String httpMethod, String statusCode,
List<Map<String, String>> patchOperations) {
MethodResponse response = getMethodResponse(region, apiId, resourceId, httpMethod, statusCode);
if (patchOperations == null) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
}
Map<String, Boolean> responseParameters = response.responseParameters() != null
? new HashMap<>(response.responseParameters()) : new HashMap<>();
Map<String, String> responseModels = response.responseModels() != null
? new HashMap<>(response.responseModels()) : new HashMap<>();
for (Map<String, String> patch : patchOperations) {
String op = patch.get("op");
String path = patch.get("path");
String value = patch.get("value");
boolean remove = "remove".equals(op);
if (path == null || (!remove && !"add".equals(op) && !"replace".equals(op)) || (!remove && value == null)) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
}
if (path.startsWith("/responseParameters/")) {
String name = unescapeJsonPointer(path.substring("/responseParameters/".length()));
if (remove) {
responseParameters.remove(name);
} else {
responseParameters.put(name, Boolean.parseBoolean(value));
}
} else if (path.startsWith("/responseModels/")) {
String contentType = unescapeJsonPointer(path.substring("/responseModels/".length()));
if (remove) {
responseModels.remove(contentType);
} else {
responseModels.put(contentType, value);
}
} else {
throw new AwsException("BadRequestException", "Invalid patch path '" + path + "'", 400);
}
}
MethodResponse updated = new MethodResponse(statusCode, responseParameters, responseModels);
getMethod(region, apiId, resourceId, httpMethod).getMethodResponses().put(statusCode, updated);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return updated;
}
// ──────────────────────────── Integrations ────────────────────────────
public Integration putIntegration(String region, String apiId, String resourceId, String httpMethod, Map<String, Object> request) {
@@ -643,18 +692,44 @@ public class ApiGatewayService {
public IntegrationResponse updateIntegrationResponse(String region, String apiId, String resourceId, String httpMethod, String statusCode, List<Map<String, String>> patchOperations) {
IntegrationResponse response = getIntegrationResponse(region, apiId, resourceId, httpMethod, statusCode);
String selectionPattern = response.selectionPattern();
if (patchOperations != null) {
for (Map<String, String> patch : patchOperations) {
String op = patch.get("op");
String path = patch.get("path");
String value = patch.get("value");
if (value == null || !("add".equals(op) || "replace".equals(op)) || !"/selectionPattern".equals(path)) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
String contentHandling = response.contentHandling();
Map<String, String> responseParameters = new HashMap<>(response.responseParameters());
Map<String, String> responseTemplates = new HashMap<>(response.responseTemplates());
if (patchOperations == null) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
}
for (Map<String, String> patch : patchOperations) {
String op = patch.get("op");
String path = patch.get("path");
String value = patch.get("value");
boolean remove = "remove".equals(op);
if (path == null || (!remove && !"add".equals(op) && !"replace".equals(op)) || (!remove && value == null)) {
throw new AwsException("BadRequestException", "Invalid patch operation", 400);
}
if ("/selectionPattern".equals(path)) {
selectionPattern = remove ? null : value;
} else if ("/contentHandling".equals(path)) {
contentHandling = remove ? null : value;
} else if (path.startsWith("/responseParameters/")) {
String name = unescapeJsonPointer(path.substring("/responseParameters/".length()));
if (remove) {
responseParameters.remove(name);
} else {
responseParameters.put(name, value);
}
selectionPattern = value;
} else if (path.startsWith("/responseTemplates/")) {
String contentType = unescapeJsonPointer(path.substring("/responseTemplates/".length()));
if (remove) {
responseTemplates.remove(contentType);
} else {
responseTemplates.put(contentType, value);
}
} else {
throw new AwsException("BadRequestException", "Invalid patch path '" + path + "'", 400);
}
}
IntegrationResponse newResponse = new IntegrationResponse(response.statusCode(), selectionPattern, response.responseParameters(), response.responseTemplates());
IntegrationResponse newResponse = new IntegrationResponse(response.statusCode(), selectionPattern,
responseParameters, responseTemplates, contentHandling);
getIntegration(region, apiId, resourceId, httpMethod).getIntegrationResponses().put(statusCode, newResponse);
resourceStore.put(resourceKey(region, apiId, resourceId), getResource(region, apiId, resourceId));
return newResponse;
@@ -10,6 +10,7 @@ import java.util.Map;
@RegisterForReflection
public record MethodResponse(
String statusCode,
Map<String, Boolean> responseParameters
Map<String, Boolean> responseParameters,
Map<String, String> responseModels
) {
}
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.SsrfProtection;
import io.github.hectorvent.floci.services.cognito.CognitoService;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
@@ -37,6 +39,7 @@ import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Verifies the RS256 signature of a token presented to an HTTP API v2 JWT authorizer, the same
@@ -66,8 +69,27 @@ public class JwtSignatureVerifier implements AutoCloseable {
private final CloseableHttpClient httpClient;
private final boolean allowPrivateNetworkTargets;
private final Map<String, CachedJwks> jwksCache = new ConcurrentHashMap<>();
private Function<String, Optional<Map<String, RSAPublicKey>>> localIssuerKeys = issuer -> Optional.empty();
/**
* An issuer that is one of this emulator's own Cognito pools is served at Floci's base URL, which
* is plain HTTP on a hostname such as {@code localhost} or a Compose service name, so the HTTPS
* and address rules below would refuse to fetch it. Its keys are read from the pool instead:
* they are the same keys the pool publishes at its JWKS URL.
*/
@Inject
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config, Instance<CognitoService> cognito) {
this(objectMapper, config);
withLocalIssuerKeys(issuer -> cognito.isResolvable()
? cognito.get().localIssuerKeys(issuer) : Optional.empty());
}
/** Where the keys of in-process issuers come from; package-private so tests can stand one in. */
JwtSignatureVerifier withLocalIssuerKeys(Function<String, Optional<Map<String, RSAPublicKey>>> localIssuerKeys) {
this.localIssuerKeys = localIssuerKeys;
return this;
}
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) {
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
config.security().allowPrivateJwtTargets());
@@ -162,6 +184,10 @@ public class JwtSignatureVerifier implements AutoCloseable {
}
private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException {
Optional<Map<String, RSAPublicKey>> local = localIssuerKeys.apply(issuer);
if (local.isPresent()) {
return local.get().get(kid);
}
CachedJwks cached = jwksCache.get(issuer);
if (cached != null && !cached.isExpired()) {
RSAPublicKey key = cached.keysByKid().get(kid);
@@ -47,7 +47,7 @@ public class AthenaJsonHandler {
resultConfiguration = mapper.treeToValue(request.get("ResultConfiguration"), ResultConfiguration.class);
}
String id = athenaService.startQueryExecution(query, workGroup, context, resultConfiguration);
String id = athenaService.startQueryExecution(query, workGroup, context, resultConfiguration, region);
yield Response.ok(Map.of("QueryExecutionId", id)).build();
}
case "GetQueryExecution" -> {
@@ -62,7 +62,7 @@ public class AthenaJsonHandler {
}
case "ListQueryExecutions" -> {
yield Response.ok(Map.of("QueryExecutionIds",
athenaService.listQueryExecutions().stream()
athenaService.listQueryExecutions(request.has("WorkGroup") ? request.get("WorkGroup").asText() : null).stream()
.map(QueryExecution::getQueryExecutionId).toList())).build();
}
case "StopQueryExecution" -> {
@@ -59,6 +59,9 @@ public class AthenaService {
public static final String DEFAULT_CATALOG = "AwsDataCatalog";
private static final String DEFAULT_OUTPUT_BUCKET = "floci-athena-results";
private static final String DEFAULT_WORKGROUP = "primary";
private static final Pattern ENGINE_FAILURE = Pattern.compile("^floci-duck \\w+ returned HTTP \\d+: (\\{.*})$", Pattern.DOTALL);
private static final com.fasterxml.jackson.databind.ObjectMapper FAILURE_READER = new com.fasterxml.jackson.databind.ObjectMapper();
private static final Pattern TRAILING_SEMICOLONS = Pattern.compile("[\\s;]+$");
private static final String DEFAULT_ENGINE_VERSION = "Athena engine version 3";
private static final long MIN_BYTES_SCANNED_CUTOFF_PER_QUERY = 10_000_000L;
private static final String WORKGROUP_RESOURCE = "workgroup/";
@@ -122,6 +125,20 @@ public class AthenaService {
String workGroup,
QueryExecutionContext context,
ResultConfiguration resultConfiguration) {
return startQueryExecution(query, workGroup, context, resultConfiguration, null);
}
/**
* Runs a query in a workgroup. As in AWS, the workgroup's result location applies when the
* request names none, and always when the workgroup enforces its configuration; an unknown
* workgroup is refused. A null region skips the workgroup lookup.
*/
public String startQueryExecution(String query,
String workGroup,
QueryExecutionContext context,
ResultConfiguration resultConfiguration,
String region) {
WorkGroupConfiguration workGroupConfiguration = region == null ? null : workGroupFor(region, workGroup).getConfiguration();
String id = UUID.randomUUID().toString();
String database = context != null && context.getDatabase() != null ? context.getDatabase() : "default";
QueryExecutionContext resolvedContext = context != null ? context : new QueryExecutionContext();
@@ -135,7 +152,7 @@ public class AthenaService {
// directory prefix — the same key is written and returned to the client.
// Statements that do not return rows must not be wrapped in a result COPY.
String outputLocation = producesResultRows(query)
? resolveOutputLocation(resultConfiguration, id)
? resolveOutputLocation(resultConfiguration, workGroupConfiguration, id)
: null;
ResultConfiguration resolvedResult = outputLocation != null
? new ResultConfiguration(outputLocation)
@@ -177,7 +194,7 @@ public class AthenaService {
if (outputLocation != null) {
ensureOutputBucket(outputLocation);
}
duckClient.execute(query, setupDdl, outputLocation);
duckClient.execute(withoutTrailingSemicolons(query), setupDdl, outputLocation);
return null;
}).onSuccess(v -> {
markSucceeded(id, execution);
@@ -214,6 +231,16 @@ public class AthenaService {
return queryStore.scan(k -> true);
}
/** A workgroup's queries, most recent first, as ListQueryExecutions returns them. */
public List<QueryExecution> listQueryExecutions(String workGroup) {
String name = workGroup == null || workGroup.isBlank() ? DEFAULT_WORKGROUP : workGroup;
return queryStore.scan(k -> true).stream()
.filter(execution -> name.equals(execution.getWorkGroup() == null ? DEFAULT_WORKGROUP : execution.getWorkGroup()))
.sorted(Comparator.comparing((QueryExecution execution) -> execution.getStatus().getSubmissionDateTime(),
Comparator.nullsLast(Comparator.naturalOrder())).reversed())
.toList();
}
public void stopQueryExecution(String id) {
QueryExecution execution = getQueryExecution(id);
execution.getStatus().setState(QueryExecutionState.CANCELLED);
@@ -531,6 +558,15 @@ public class AthenaService {
return properties;
}
/**
* Athena accepts one statement with or without a closing semicolon. The engine wraps the
* statement in {@code COPY (...) TO ...}, where a semicolon is a syntax error, so it is
* dropped before the query is handed over.
*/
static String withoutTrailingSemicolons(String query) {
return query == null ? null : TRAILING_SEMICOLONS.matcher(query).replaceFirst("");
}
private static boolean producesResultRows(String query) {
return RESULT_STATEMENT_PATTERN.matcher(statementText(query)).find();
}
@@ -576,17 +612,53 @@ public class AthenaService {
private void markFailed(String id, QueryExecution execution, Throwable failure) {
execution.getStatus().setState(QueryExecutionState.FAILED);
execution.getStatus().setStateChangeReason(failure.getMessage());
execution.getStatus().setStateChangeReason(failureReason(failure.getMessage()));
queryStore.put(id, execution);
}
/**
* The engine's own explanation of a failed query, as Athena reports it: without the HTTP
* envelope of the engine call, and without the echoed statement, which shows the
* internal {@code COPY ... TO} wrapper rather than the query that was sent.
*/
static String failureReason(String message) {
if (message == null) {
return null;
}
Matcher envelope = ENGINE_FAILURE.matcher(message);
if (!envelope.matches()) {
return message;
}
try {
String engineMessage = FAILURE_READER.readTree(envelope.group(1)).path("message").asText("");
if (engineMessage.isBlank()) {
return message;
}
int echo = engineMessage.indexOf("\n\nLINE ");
return (echo >= 0 ? engineMessage.substring(0, echo) : engineMessage).strip();
} catch (Exception e) {
return message;
}
}
private record CreateDatabaseDdl(String name, boolean ifNotExists, String comment,
String location, Map<String, String> properties) {
}
private String resolveOutputLocation(ResultConfiguration rc, String queryId) {
String base = (rc != null && rc.getOutputLocation() != null && !rc.getOutputLocation().isBlank())
private String resolveOutputLocation(ResultConfiguration rc, WorkGroupConfiguration workGroupConfiguration, String queryId) {
String requested = rc != null && rc.getOutputLocation() != null && !rc.getOutputLocation().isBlank()
? rc.getOutputLocation()
: null;
String workGroupLocation = workGroupConfiguration != null
&& workGroupConfiguration.getResultConfiguration() != null
&& workGroupConfiguration.getResultConfiguration().getOutputLocation() != null
&& !workGroupConfiguration.getResultConfiguration().getOutputLocation().isBlank()
? workGroupConfiguration.getResultConfiguration().getOutputLocation()
: null;
boolean enforced = workGroupConfiguration != null && Boolean.TRUE.equals(workGroupConfiguration.getEnforceWorkGroupConfiguration());
String base = enforced && workGroupLocation != null ? workGroupLocation
: requested != null ? requested
: workGroupLocation != null ? workGroupLocation
: "s3://" + DEFAULT_OUTPUT_BUCKET + "/results/";
return base.endsWith("/") ? base + queryId + ".csv" : base + "/" + queryId + ".csv";
}
@@ -692,6 +764,12 @@ public class AthenaService {
return engineVersion;
}
private WorkGroup workGroupFor(String region, String name) {
return name == null || name.isBlank() || DEFAULT_WORKGROUP.equals(name)
? primaryWorkGroup(region)
: requireWorkGroup(region, name);
}
private WorkGroup primaryWorkGroup(String region) {
return workGroupStore.get(workGroupKey(region, DEFAULT_WORKGROUP)).orElseGet(() -> {
WorkGroup workGroup = new WorkGroup();
@@ -0,0 +1,183 @@
package io.github.hectorvent.floci.services.cloudformation.provisioners;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
@ApplicationScoped
public class CloudWatchCompositeAlarmCfnProvisioner implements CfnResourceProvisioner {
private static final String TYPE = "AWS::CloudWatch::CompositeAlarm";
private static final String SNAPSHOT = "FlociCompositeAlarmSnapshot";
private static final String TAG_KEYS = "FlociCompositeAlarmTagKeys";
private static final String EXPLICIT_NAME = "FlociCompositeAlarmExplicitName";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final CloudWatchMetricsService service;
public CloudWatchCompositeAlarmCfnProvisioner(CloudWatchMetricsService service) {
this.service = service;
}
@Override
public Set<String> resourceTypes() {
return Set.of(TYPE);
}
@Override
public void provision(StackResource resource, JsonNode props, ProvisionContext ctx) {
for (String unsupported : List.of("ActionsSuppressor", "ActionsSuppressorWaitPeriod",
"ActionsSuppressorExtensionPeriod")) {
if (props.has(unsupported)) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
}
if (resource.getAttributes().containsKey(SNAPSHOT)) {
throw new AwsException("ResourceConflictException", "Alarm update cleanup is still pending", 400);
}
String explicit = ctx.resolveOptional(props, "AlarmName");
boolean removedName = explicit == null && "true".equals(resource.getAttributes().get(EXPLICIT_NAME));
String name = removedName ? ctx.generatePhysicalName(resource.getLogicalId(), 255, false)
: ctx.stablePhysicalName(explicit, resource.getLogicalId(), 255, false);
CompositeAlarm alarm = new CompositeAlarm();
alarm.setAlarmName(name);
alarm.setAlarmDescription(ctx.resolveOptional(props, "AlarmDescription"));
alarm.setAlarmRule(ctx.resolveOptional(props, "AlarmRule"));
alarm.setActionsEnabled(!"false".equals(ctx.resolveOptional(props, "ActionsEnabled")));
alarm.setTags(ctx.resolveTags(props, "Tags"));
actions(props, "AlarmActions", ctx, alarm.getAlarmActions());
actions(props, "OKActions", ctx, alarm.getOkActions());
actions(props, "InsufficientDataActions", ctx, alarm.getInsufficientDataActions());
Map<String, String> desiredTags = Map.copyOf(alarm.getTags());
Map<String, String> priorAttributes = Map.copyOf(resource.getAttributes());
boolean inPlace = ctx.reusesPriorEntity(name);
String updateSnapshot = null;
if (inPlace) {
ObjectNode snapshot = MAPPER.createObjectNode().put("region", ctx.region()).put("name", name);
List<CompositeAlarm> previous = service.describeCompositeAlarms(List.of(name), null, ctx.region());
if (!previous.isEmpty()) {
snapshot.set("alarm", MAPPER.valueToTree(previous.getFirst()));
}
snapshot.set("attributes", MAPPER.valueToTree(priorAttributes));
Set<String> changedKeys = new TreeSet<>(desiredTags.keySet());
changedKeys.addAll(tagKeys(resource.getAttributes().get(TAG_KEYS)));
snapshot.set("changedTagKeys", MAPPER.valueToTree(changedKeys));
updateSnapshot = snapshot.toString();
}
service.putCompositeAlarm(alarm, ctx.region(), inPlace);
if (inPlace) {
resource.getAttributes().put(SNAPSHOT, updateSnapshot);
reconcileTags(alarm.getAlarmArn(), tagKeys(priorAttributes.get(TAG_KEYS)), desiredTags, ctx.region());
}
resource.getAttributes().put(TAG_KEYS, MAPPER.valueToTree(desiredTags.keySet()).toString());
resource.setPhysicalId(name);
resource.getAttributes().put("Arn", alarm.getAlarmArn());
resource.getAttributes().put(EXPLICIT_NAME, Boolean.toString(explicit != null));
ReplacementCleanup.record(resource, ctx, priorAttributes);
}
private static void actions(JsonNode props, String field, ProvisionContext ctx, List<String> target) {
JsonNode values = ctx.engine().resolveNode(props.path(field));
if (values != null && values.isArray()) {
values.forEach(value -> target.add(value.asText()));
}
}
private static Set<String> tagKeys(String json) {
Set<String> keys = new TreeSet<>();
if (json != null) {
try {
MAPPER.readTree(json).forEach(value -> keys.add(value.asText()));
} catch (JsonProcessingException error) {
throw new IllegalStateException("Cannot read composite alarm tag ownership", error);
}
}
return keys;
}
private void reconcileTags(String arn, Set<String> owned, Map<String, String> desired, String region) {
List<String> removed = owned.stream().filter(key -> !desired.containsKey(key)).toList();
if (!removed.isEmpty()) {
service.untagResource(arn, removed, region);
}
if (!desired.isEmpty()) {
service.tagResource(arn, desired, region);
}
}
@Override
public void delete(String type, String physicalId, String region) {
service.deleteAlarms(List.of(physicalId), region);
}
@Override
public boolean retainsFailedUpdateState(StackResource resource) {
return resource.getAttributes().containsKey(SNAPSHOT);
}
@Override
public boolean rollbackUpdate(StackResource resource) {
if (ReplacementCleanup.rollback(resource, this::delete)) {
return true;
}
String raw = resource.getAttributes().get(SNAPSHOT);
if (raw == null) {
return false;
}
try {
JsonNode snapshot = MAPPER.readTree(raw);
String region = snapshot.path("region").asText();
if (snapshot.has("alarm")) {
CompositeAlarm original = MAPPER.treeToValue(snapshot.get("alarm"), CompositeAlarm.class);
Map<String, String> originalTags = Map.copyOf(original.getTags());
service.putCompositeAlarm(original, region);
Set<String> changed = tagKeys(snapshot.path("changedTagKeys").toString());
reconcileTags(original.getAlarmArn(), changed, originalTags.entrySet().stream()
.filter(entry -> changed.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), region);
} else {
delete(TYPE, snapshot.path("name").asText(), region);
}
resource.getAttributes().clear();
snapshot.path("attributes").fields().forEachRemaining(entry ->
resource.getAttributes().put(entry.getKey(), entry.getValue().asText()));
} catch (JsonProcessingException error) {
throw new IllegalStateException("Cannot read composite alarm rollback state", error);
}
return true;
}
@Override
public boolean hasReplacementUpdate(StackResource resource) {
return ReplacementCleanup.hasReplacement(resource);
}
@Override
public String updateCleanupPhysicalId(StackResource resource) {
return ReplacementCleanup.cleanupPhysicalId(resource);
}
@Override
public UpdateCleanupResult completeUpdate(StackResource resource) {
if (resource.getAttributes().containsKey(SNAPSHOT) && !ReplacementCleanup.hasReplacement(resource)) {
return new UpdateCleanupResult(true, true, null, 0, null);
}
return ReplacementCleanup.complete(resource, this::delete);
}
@Override
public void clearUpdate(StackResource resource) {
resource.getAttributes().remove(SNAPSHOT);
ReplacementCleanup.clear(resource);
}
}
@@ -109,6 +109,8 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
List<AttributeDefinition> attrDefs = new ArrayList<>();
List<GlobalSecondaryIndex> gsis = new ArrayList<>();
List<LocalSecondaryIndex> lsis = new ArrayList<>();
Long readCapacity = null;
Long writeCapacity = null;
if (props != null && props.has("KeySchema")) {
for (JsonNode ks : props.get("KeySchema")) {
@@ -124,6 +126,11 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
attrDefs.add(new AttributeDefinition(attrName, attrType));
}
}
if (props != null && props.has("ProvisionedThroughput")) {
JsonNode throughput = props.get("ProvisionedThroughput");
readCapacity = Long.valueOf(engine.resolve(throughput.get("ReadCapacityUnits")));
writeCapacity = Long.valueOf(engine.resolve(throughput.get("WriteCapacityUnits")));
}
if (props != null && props.has("GlobalSecondaryIndexes")) {
for (JsonNode gsiNode : props.get("GlobalSecondaryIndexes")) {
@@ -179,7 +186,7 @@ public class DynamoDbCfnProvisioner implements CfnResourceProvisioner {
TableDefinition table;
try {
table = dynamoDbService.createTable(tableName, keySchema, attrDefs, null, null, gsis, lsis, region);
table = dynamoDbService.createTable(tableName, keySchema, attrDefs, readCapacity, writeCapacity, gsis, lsis, region);
} catch (AwsException e) {
if (!"ResourceInUseException".equals(e.getErrorCode())) {
throw e;
@@ -111,7 +111,7 @@ public class LambdaCfnProvisioner implements CfnResourceProvisioner {
"Cannot replace Lambda function " + r.getPhysicalId()
+ " without a new FunctionName", 400);
}
func = createLambdaFunction(region, desired, !replacement);
func = createLambdaFunction(region, desired, !replacement, r);
if (replacement && r.getPhysicalId() != null) {
deleteReplacedLambda(region, r.getPhysicalId());
}
@@ -343,13 +343,15 @@ public class LambdaCfnProvisioner implements CfnResourceProvisioner {
return !Objects.equals(existingPackageType, desired.packageType());
}
private LambdaFunction createLambdaFunction(String region, LambdaDesiredState desired, boolean allowAdopt) {
private LambdaFunction createLambdaFunction(String region, LambdaDesiredState desired, boolean allowAdopt,
StackResource resource) {
try {
return lambdaService.createFunction(region, desired.createRequest());
} catch (AwsException e) {
if (allowAdopt && ("ResourceConflictException".equals(e.getErrorCode())
|| (e.getMessage() != null && e.getMessage().contains("Function already exist")))) {
return lambdaService.getFunction(region, desired.functionName());
LambdaFunction existing = lambdaService.getFunction(region, desired.functionName());
return updateLambdaFunction(region, existing, desired, resource);
}
throw e;
}
@@ -85,16 +85,9 @@ public class LambdaVersionAliasCfnProvisioner implements CfnResourceProvisioner
}
String description = ctx.resolveOptional(props, "Description");
// Version resources are immutable in CloudFormation. An update re-invokes provision with
// the prior physical id, and publishing again would mint a new version and repoint every
// Ref to it, so the version this resource already published is kept while it still exists.
String previous = r.getPhysicalId();
if (previous != null && existingVersionIsCurrent(ctx.region(), functionName, previous)) {
r.getAttributes().put("Version", previous.substring(previous.lastIndexOf(':') + 1));
r.getAttributes().put("FunctionArn", previous);
return;
}
// Lambda deduplicates an unchanged publish by $LATEST revision. Calling it here is
// essential on UpdateStack: after a dependent function updates its code, the Version
// must snapshot that new revision rather than retaining its prior physical id.
LambdaFunction version = lambdaService.publishVersion(ctx.region(), functionName, description);
// Matches real CloudFormation: Ref returns the qualified ARN, GetAtt Version the version
@@ -104,26 +97,6 @@ public class LambdaVersionAliasCfnProvisioner implements CfnResourceProvisioner
r.getAttributes().put("FunctionArn", version.getFunctionArn());
}
/** True when {@code physicalId} names a still-published version of the function in the template. */
private boolean existingVersionIsCurrent(String region, String functionName, String physicalId) {
int sep = physicalId.lastIndexOf(':');
if (sep <= 0) {
return false;
}
String version = physicalId.substring(sep + 1);
String baseArn = physicalId.substring(0, sep);
try {
LambdaFunction fn = lambdaService.getFunction(region, functionName);
if (!baseArn.equals(fn.getFunctionArn().replace(":$LATEST", ""))) {
return false; // the FunctionName property now points at a different function
}
return lambdaService.listVersionsByFunction(region, functionName).stream()
.anyMatch(v -> version.equals(v.getVersion()));
} catch (AwsException e) {
return false;
}
}
private void provisionAlias(StackResource r, JsonNode props, ProvisionContext ctx) {
String functionName = ctx.resolveOptional(props, "FunctionName");
String aliasName = ctx.resolveOptional(props, "Name");
@@ -9,9 +9,11 @@ import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import org.jboss.logging.Logger;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The replacement lifecycle for provisioners whose replacing update simply creates the new entity
@@ -121,6 +123,22 @@ public final class ReplacementCleanup {
return cleanup != null && cleanup.path("displaced").size() > 0;
}
/** The physical IDs of replaced resources this resource still has to delete. */
static Set<String> owedPhysicalIds(StackResource r) {
Set<String> owed = new HashSet<>();
ObjectNode cleanup = read(r);
if (cleanup == null) {
return owed;
}
for (JsonNode entry : cleanup.path("displaced")) {
String physicalId = entry.path("physicalId").asText(null);
if (physicalId != null) {
owed.add(physicalId);
}
}
return owed;
}
/**
* The entity the cleanup is working on, or null when none is owed or {@code UpdateReplacePolicy:
* Retain} keeps it. The engine announces one physical id per resource, so a resource owing more
@@ -468,7 +468,7 @@ public class CloudFrontController {
@QueryParam("Type") String type) {
try {
Page<OriginRequestPolicy> page = page(
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems)),
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems), type),
maxItems, OriginRequestPolicy::getId);
XmlBuilder xml = new XmlBuilder()
@@ -479,7 +479,8 @@ public class CloudFrontController {
.start("Items");
for (OriginRequestPolicy p : page.items()) {
xml.start("OriginRequestPolicySummary")
.elem("Type", "custom")
.elem("Type", CloudFrontService.isManagedOriginRequestPolicy(p.getId())
? "managed" : "custom")
.raw(xmlOriginRequestPolicyResponse(p))
.end("OriginRequestPolicySummary");
}
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.cloudfront;
import io.github.hectorvent.floci.core.common.SsrfProtection;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.config.TlsConfig;
@@ -13,33 +14,35 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.routing.RoutingSupport;
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpVersion;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.apache.hc.core5.util.Timeout;
import org.jboss.logging.Logger;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;
/**
@@ -49,8 +52,11 @@ import java.util.stream.Collectors;
*/
final class CloudFrontOriginHttpClient implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(CloudFrontOriginHttpClient.class);
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(30);
static final int MAX_CONNECTIONS = 100;
private final CloseableHttpClient client;
@@ -84,8 +90,10 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
.setDnsResolver(validatingResolver)
.setDefaultConnectionConfig(connectionConfig)
.setDefaultTlsConfig(tlsConfig)
.setMaxConnTotal(100)
.setMaxConnPerRoute(20);
// CloudFront has no per-origin connection limit, and a streamed response holds its
// connection for the whole transfer, so one origin may use the whole pool.
.setMaxConnTotal(MAX_CONNECTIONS)
.setMaxConnPerRoute(MAX_CONNECTIONS);
if (sslContext != null) {
connectionManagerBuilder.setTlsSocketStrategy(ClientTlsStrategyBuilder.create()
.setSslContext(sslContext)
@@ -111,28 +119,159 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
.build();
}
HttpResponse<byte[]> send(HttpRequest request, HttpResponse.BodyHandler<byte[]> bodyHandler)
throws IOException, InterruptedException {
return send(request, Map.of(), bodyHandler);
/**
* An origin response whose body has not been read. The caller reads {@code body} as it needs it
* and closes it, which releases the connection. {@code contentLength} is the length the body
* arrives with, {@code -1} when it comes chunked or with none.
*/
record OpenResponse(int statusCode, HttpHeaders headers, long contentLength, InputStream body) {
}
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
HttpResponse.BodyHandler<byte[]> bodyHandler)
/**
* Sends {@code request} with {@code forwardedHeaders} added and {@code requestBody}, when it is
* not {@code null}, streamed as the entity, with {@code requestBodyLength} as its
* {@code Content-Length}, or chunked when the length is negative. Each origin header replaces
* every same-named request or forwarded header, so a viewer cannot repeat a header to smuggle its
* own value past an origin custom header. The response comes back before its body is read, so
* neither body has to fit in memory.
*/
OpenResponse open(HttpRequest request,
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
Map<String, String> originHeaders, InputStream requestBody, long requestBodyLength)
throws IOException, InterruptedException {
// No entity content type: the caller's Content-Type request header is sent as is.
HttpUriRequestBase originRequest = originRequest(request, forwardedHeaders, originHeaders,
requestBody != null ? new InputStreamEntity(requestBody, requestBodyLength, null) : null);
ClassicHttpResponse response;
try {
response = client.executeOpen(RoutingSupport.determineHost(originRequest), originRequest, context(request));
} catch (HttpException e) {
throw new IOException("CloudFront origin request has no target host", e);
} catch (InterruptedIOException e) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("CloudFront origin request interrupted");
}
throw e;
}
try {
HttpEntity entity = response.getEntity();
InputStream body = entity != null
? new ResponseBody(entity.getContent(), originRequest, response)
: new ResponseBody(InputStream.nullInputStream(), null, response);
// The entity's length follows the framing the body arrives with; with no entity, as for a
// HEAD, the header describes the body a GET would get.
long contentLength = entity != null ? entity.getContentLength() : headerContentLength(response);
return new OpenResponse(response.getCode(), responseHeaders(response), contentLength, body);
} catch (IOException | RuntimeException e) {
originRequest.cancel();
closeAborted(response);
throw e;
}
}
/**
* An origin response body that releases its connection when it is closed. Closed at its end, the
* connection goes back to the pool. Closed before its end, as when a viewer goes away or a custom
* error page replaces it, the request is cancelled and the connection closed, since closing the
* response normally reads the rest of the body to reuse the connection, however long it is.
*/
private static final class ResponseBody extends FilterInputStream {
private final HttpUriRequestBase request;
private final ClassicHttpResponse response;
private boolean finished;
ResponseBody(InputStream content, HttpUriRequestBase request, ClassicHttpResponse response) {
super(content);
this.request = request;
this.response = response;
}
@Override
public int read() throws IOException {
int b = super.read();
finished |= b < 0;
return b;
}
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
int read = super.read(buffer, offset, length);
finished |= read < 0;
return read;
}
@Override
public void close() throws IOException {
if (finished || request == null) {
try {
super.close();
} finally {
response.close();
}
return;
}
request.cancel();
closeAborted(response);
}
}
/** Closes a response whose request was cancelled; reading the rest of it fails, as it should. */
private static void closeAborted(ClassicHttpResponse response) {
try {
response.close();
} catch (IOException e) {
LOG.debugv("Closed an unfinished CloudFront origin response: {0}", e.getMessage());
}
}
private static long headerContentLength(ClassicHttpResponse response) {
Header header = response.getFirstHeader("Content-Length");
if (header == null) {
return -1;
}
try {
return Long.parseLong(header.getValue().trim());
} catch (NumberFormatException e) {
LOG.debugv("Ignoring an invalid custom-origin Content-Length: {0}", e.getMessage());
return -1;
}
}
private HttpUriRequestBase originRequest(HttpRequest request,
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
Map<String, String> originHeaders, HttpEntity entity)
throws IOException, InterruptedException {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("CloudFront origin request interrupted");
}
if (request.bodyPublisher().filter(publisher -> publisher.contentLength() != 0).isPresent()) {
throw new IOException("CloudFront origin request bodies are not supported");
throw new IOException("CloudFront origin request bodies must be passed separately");
}
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
.setUri(request.uri())
.setVersion(HttpVersion.HTTP_1_1);
request.headers().map().forEach((name, values) ->
values.forEach(value -> builder.addHeader(name, value)));
originHeaders.forEach(builder::setHeader);
// A request that can be cancelled, so a response closed before its end is not read to the end.
HttpUriRequestBase builder = new HttpUriRequestBase(request.method(), request.uri());
builder.setVersion(HttpVersion.HTTP_1_1);
Set<String> replaced = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
replaced.addAll(originHeaders.keySet());
request.headers().map().forEach((name, values) -> {
if (!replaced.contains(name)) {
values.forEach(value -> builder.addHeader(name, value));
}
});
for (CloudFrontOriginRequestBuilder.Header header : forwardedHeaders) {
if (!replaced.contains(header.name())) {
builder.addHeader(header.name(), header.value());
}
}
originHeaders.forEach(builder::addHeader);
if (entity != null) {
builder.setEntity(entity);
}
return builder;
}
private static HttpClientContext context(HttpRequest request) {
HttpClientContext context = HttpClientContext.create();
Duration responseTimeout = request.timeout().orElse(RESPONSE_TIMEOUT);
context.setRequestConfig(RequestConfig.custom()
@@ -142,28 +281,16 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
.setContentCompressionEnabled(false)
.setProtocolUpgradeEnabled(false)
.build());
return context;
}
try {
return client.execute(builder.build(), context, response -> {
Map<String, List<String>> responseHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (Header header : response.getHeaders()) {
responseHeaders.computeIfAbsent(header.getName(), ignored -> new ArrayList<>())
.add(header.getValue());
}
byte[] body = response.getEntity() != null
? EntityUtils.toByteArray(response.getEntity())
: new byte[0];
return new PinnedHttpResponse(
response.getCode(), request,
HttpHeaders.of(responseHeaders, (name, value) -> true),
body, request.uri());
});
} catch (InterruptedIOException e) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("CloudFront origin request interrupted");
}
throw e;
private static HttpHeaders responseHeaders(ClassicHttpResponse response) {
Map<String, List<String>> responseHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (Header header : response.getHeaders()) {
responseHeaders.computeIfAbsent(header.getName(), ignored -> new ArrayList<>())
.add(header.getValue());
}
return HttpHeaders.of(responseHeaders, (name, value) -> true);
}
@Override
@@ -171,29 +298,6 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
client.close();
}
private record PinnedHttpResponse(
int statusCode,
HttpRequest request,
HttpHeaders headers,
byte[] body,
URI uri) implements HttpResponse<byte[]> {
@Override
public Optional<HttpResponse<byte[]>> previousResponse() {
return Optional.empty();
}
@Override
public Optional<SSLSession> sslSession() {
return Optional.empty();
}
@Override
public HttpClient.Version version() {
return HttpClient.Version.HTTP_1_1;
}
}
private record ValidatingDnsResolver(DnsResolver delegate, Set<String> allowedHosts)
implements DnsResolver {
@@ -0,0 +1,497 @@
package io.github.hectorvent.floci.services.cloudfront;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
/**
* Decides what CloudFront sends to a custom origin for one viewer request: the viewer headers,
* cookies and query strings the matched cache behavior forwards, and the headers CloudFront adds on
* its own. Implemented to the CloudFront Developer Guide:
*
* <ul>
* <li>With a cache policy, only what the cache policy's
* {@code ParametersInCacheKeyAndForwardedToOrigin} or the origin request policy selects is
* forwarded; the two selections are unioned.</li>
* <li>Without a cache policy (legacy cache settings), {@code ForwardedValues} applies, and viewer
* headers are forwarded by default except the ones the custom-origin request table says
* CloudFront removes.</li>
* <li>Every origin request carries {@code User-Agent: Amazon CloudFront} unless the viewer's
* {@code User-Agent} is forwarded, an {@code X-Forwarded-For} ending in the viewer address,
* CloudFront's {@code Via} hop and an {@code X-Amz-Cf-Id}. {@code Host} is the origin's own
* name unless the viewer's {@code Host} is forwarded.</li>
* </ul>
*
* <p>This class holds no state and does no I/O. Origin custom headers are applied afterwards by the
* transport, which lets them replace same-named viewer headers.
*/
final class CloudFrontOriginRequestBuilder {
static final String CLOUDFRONT_USER_AGENT = "Amazon CloudFront";
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
/**
* Never copied from a viewer request: hop-by-hop and framing fields the transport owns, and
* fields CloudFront removes or sets itself whatever the policy says.
*/
private static final Set<String> NEVER_FORWARDED = Set.of(
"connection", "keep-alive", "proxy-connection", "proxy-authenticate",
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
"content-length", "expect", "x-amz-cf-id", "x-real-ip", "x-forwarded-proto");
/** Removed by legacy cache settings unless the behavior's {@code Headers} names them. */
private static final Set<String> LEGACY_REMOVED = Set.of(
"accept", "accept-charset", "accept-language", "authorization", "host", "referer",
"user-agent", "x-http-method-override");
private static final String FORWARDED_PROTO = "CloudFront-Forwarded-Proto";
private static final String VIEWER_ADDRESS = "CloudFront-Viewer-Address";
private static final String VIEWER_HTTP_VERSION = "CloudFront-Viewer-Http-Version";
/** The CloudFront request headers Floci can derive from a local viewer connection. */
private static final List<String> GENERATED_HEADERS =
List.of(FORWARDED_PROTO, VIEWER_ADDRESS, VIEWER_HTTP_VERSION);
private CloudFrontOriginRequestBuilder() {
}
/** One request header field, in the order it was received or will be sent. */
record Header(String name, String value) {
}
/**
* The viewer request as CloudFront received it. {@code rawQuery} has CloudFront's signing
* parameters removed already; {@code httpVersion} is the viewer's protocol version, such as
* {@code 1.1} or {@code 2.0}.
*/
record ViewerRequest(String method, List<Header> headers, String rawQuery,
String clientAddress, int clientPort, String scheme, String httpVersion) {
}
/**
* The forwarding configuration of the cache behavior that matched the request. A behavior
* without a cache policy uses legacy cache settings, where only {@code forwardedValues} applies.
* The policy maps use the shapes {@link CloudFrontPolicyConfigCodec} stores.
*/
record Forwarding(boolean legacy, Map<?, ?> cachePolicyParameters,
Map<?, ?> originRequestPolicy, Map<?, ?> forwardedValues,
boolean optionsCached) {
static Forwarding legacy(Map<?, ?> forwardedValues, boolean optionsCached) {
return new Forwarding(true, null, null, forwardedValues, optionsCached);
}
static Forwarding policies(Map<?, ?> cachePolicyParameters, Map<?, ?> originRequestPolicy,
boolean optionsCached) {
return new Forwarding(false, cachePolicyParameters, originRequestPolicy, null,
optionsCached);
}
}
/**
* What CloudFront sends to the origin before origin custom headers are applied. {@code rawQuery}
* is {@code null} when no query string is forwarded. A {@code Host} header is present only when
* the viewer's {@code Host} is forwarded.
*/
record OriginRequest(List<Header> headers, String rawQuery) {
static OriginRequest empty() {
return new OriginRequest(List.of(), null);
}
}
static OriginRequest build(ViewerRequest viewer, Forwarding forwarding, String viaHost) {
Selections selections = forwarding.legacy()
? legacySelections(forwarding.forwardedValues())
: policySelections(forwarding.cachePolicyParameters(),
forwarding.originRequestPolicy());
Set<String> connectionTokens = connectionTokens(viewer.headers());
boolean forwardsAuthorization = BODY_METHODS.contains(viewer.method())
|| ("OPTIONS".equals(viewer.method()) && !forwarding.optionsCached());
List<Header> headers = new ArrayList<>();
List<String> cookieHeaders = new ArrayList<>();
List<String> forwardedFor = new ArrayList<>();
List<String> via = new ArrayList<>();
List<String> acceptEncoding = new ArrayList<>();
boolean userAgentForwarded = false;
for (Header header : viewer.headers()) {
String name = normalize(header.name());
if (name.isEmpty() || NEVER_FORWARDED.contains(name) || connectionTokens.contains(name)
|| name.startsWith("x-edge-") || name.startsWith("cloudfront-")) {
continue;
}
switch (name) {
case "cookie" -> cookieHeaders.add(header.value());
case "x-forwarded-for" -> forwardedFor.add(header.value());
case "via" -> {
if (selections.forwardsHeader(name)) {
via.add(header.value());
}
}
case "accept-encoding" -> {
if (selections.normalizesAcceptEncoding()) {
acceptEncoding.add(header.value());
} else if (selections.forwardsHeader(name)) {
headers.add(header);
}
}
case "authorization" -> {
if (forwardsAuthorization || selections.forwardsHeader(name)) {
headers.add(header);
}
}
case "content-type" -> {
if (BODY_METHODS.contains(viewer.method()) || selections.forwardsHeader(name)) {
headers.add(header);
}
}
default -> {
if (selections.forwardsHeader(name)) {
headers.add(header);
userAgentForwarded |= "user-agent".equals(name);
}
}
}
}
if (!userAgentForwarded) {
headers.add(new Header("User-Agent", CLOUDFRONT_USER_AGENT));
}
if (!acceptEncoding.isEmpty()) {
String normalized = normalizedAcceptEncoding(acceptEncoding, selections);
if (normalized != null) {
headers.add(new Header("Accept-Encoding", normalized));
}
}
String cookies = forwardedCookies(cookieHeaders, selections.cookies());
if (cookies != null) {
headers.add(new Header("Cookie", cookies));
}
for (String generated : GENERATED_HEADERS) {
String value = selections.generatesHeader(normalize(generated))
? generatedValue(generated, viewer) : null;
if (value != null) {
headers.add(new Header(generated, value));
}
}
String clientForwardedFor = forwardedFor(forwardedFor, viewer.clientAddress());
if (clientForwardedFor != null) {
headers.add(new Header("X-Forwarded-For", clientForwardedFor));
}
via.add(viewerProtocolVersion(viewer.httpVersion()) + " " + viaHost + " (CloudFront)");
headers.add(new Header("Via", String.join(", ", via)));
headers.add(new Header("X-Amz-Cf-Id", requestId()));
return new OriginRequest(List.copyOf(headers),
forwardedQuery(viewer.rawQuery(), selections.queryStrings()));
}
private static Selections policySelections(Map<?, ?> cachePolicyParameters,
Map<?, ?> originRequestPolicy) {
List<Selection> headers = new ArrayList<>();
List<Selection> cookies = new ArrayList<>();
List<Selection> queryStrings = new ArrayList<>();
for (Map<?, ?> source : new Map<?, ?>[] {cachePolicyParameters, originRequestPolicy}) {
if (source == null) {
continue;
}
headers.add(Selection.of(source.get("HeadersConfig"), "HeaderBehavior", "Headers", true));
cookies.add(Selection.of(source.get("CookiesConfig"), "CookieBehavior", "Cookies", false));
queryStrings.add(Selection.of(
source.get("QueryStringsConfig"), "QueryStringBehavior", "QueryStrings", false));
}
boolean gzip = cachePolicyParameters != null
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingGzip"));
boolean brotli = cachePolicyParameters != null
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingBrotli"));
return new Selections(false, headers, false, cookies, queryStrings, gzip, brotli,
gzip || brotli);
}
/**
* Legacy {@code ForwardedValues}: headers are forwarded by default apart from the ones CloudFront
* removes, {@code Headers} adds names back (or everything with {@code *}), cookie names may use
* {@code *} and {@code ?} wildcards, and {@code QueryString=true} forwards every query string
* ({@code QueryStringCacheKeys} only narrows the cache key).
*/
private static Selections legacySelections(Map<?, ?> forwardedValues) {
List<String> headerNames = stringList(forwardedValues, "Headers");
boolean allHeaders = headerNames.contains("*");
Selection headers = allHeaders
? new Selection("all", List.of(), true, false)
: new Selection("whitelist", headerNames, true, false);
String cookiesForward = forwardedValues != null && forwardedValues.get("CookiesForward") != null
? forwardedValues.get("CookiesForward").toString() : "none";
Selection cookies = new Selection(cookiesForward, stringList(forwardedValues, "CookieNames"),
false, true);
boolean queryString = forwardedValues != null && isTrue(forwardedValues.get("QueryString"));
Selection queryStrings = new Selection(queryString ? "all" : "none", List.of(), false, false);
return new Selections(true, List.of(headers), !allHeaders, List.of(cookies),
List.of(queryStrings), true, true, !headers.includes("accept-encoding"));
}
private static Set<String> connectionTokens(List<Header> headers) {
Set<String> tokens = new HashSet<>();
for (Header header : headers) {
if ("connection".equals(normalize(header.name())) && header.value() != null) {
for (String token : header.value().split(",")) {
String normalized = normalize(token);
if (!normalized.isEmpty()) {
tokens.add(normalized);
}
}
}
}
return tokens;
}
/**
* Accept-Encoding normalization: the formats caching is enabled for that the viewer accepts, as
* {@code br,gzip}, {@code gzip} or {@code br}. A cache policy sends {@code identity} when the
* viewer accepts none of them; legacy settings drop the header instead.
*/
private static String normalizedAcceptEncoding(List<String> values, Selections selections) {
Set<String> accepted = new HashSet<>();
for (String value : values) {
for (String coding : value.split(",")) {
int parameters = coding.indexOf(';');
String name = normalize(parameters >= 0 ? coding.substring(0, parameters) : coding);
if (!name.isEmpty()) {
accepted.add(name);
}
}
}
boolean brotli = selections.brotli() && accepted.contains("br");
boolean gzip = selections.gzip() && accepted.contains("gzip");
if (brotli && gzip) {
return "br,gzip";
}
if (brotli) {
return "br";
}
if (gzip) {
return "gzip";
}
return selections.legacy() ? null : "identity";
}
/**
* The viewer cookies the behavior forwards, sorted by name as CloudFront sorts them. Pairs that are
* not {@code name=value}, and names starting with {@code $}, are dropped.
*/
private static String forwardedCookies(List<String> cookieHeaders, List<Selection> selections) {
List<String[]> cookies = new ArrayList<>();
for (String cookieHeader : cookieHeaders) {
if (cookieHeader == null) {
continue;
}
for (String pair : cookieHeader.split(";")) {
int eq = pair.indexOf('=');
String name = eq > 0 ? pair.substring(0, eq).trim() : "";
if (name.isEmpty() || name.startsWith("$")) {
continue;
}
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
cookies.add(new String[] {name, pair.substring(eq + 1).trim()});
}
}
}
if (cookies.isEmpty()) {
return null;
}
cookies.sort(Comparator.comparing(cookie -> cookie[0]));
List<String> pairs = new ArrayList<>(cookies.size());
for (String[] cookie : cookies) {
pairs.add(cookie[0] + "=" + cookie[1]);
}
return String.join("; ", pairs);
}
/** The raw query pairs the behavior forwards, in viewer order and with their original encoding. */
private static String forwardedQuery(String rawQuery, List<Selection> selections) {
if (rawQuery == null || rawQuery.isEmpty()) {
return null;
}
if (selections.stream().anyMatch(selection -> "all".equals(selection.behavior()))) {
return rawQuery;
}
List<String> kept = new ArrayList<>();
for (String pair : rawQuery.split("&")) {
if (pair.isEmpty()) {
continue;
}
int eq = pair.indexOf('=');
String name = decodeQueryName(eq >= 0 ? pair.substring(0, eq) : pair);
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
kept.add(pair);
}
}
return kept.isEmpty() ? null : String.join("&", kept);
}
private static String decodeQueryName(String rawName) {
try {
return URLDecoder.decode(rawName, StandardCharsets.UTF_8);
} catch (IllegalArgumentException malformed) {
// A name that is not valid percent-encoding can only match a policy entry literally.
return rawName;
}
}
private static String generatedValue(String header, ViewerRequest viewer) {
return switch (header) {
case FORWARDED_PROTO -> viewer.scheme() != null
? viewer.scheme().toLowerCase(Locale.ROOT) : null;
case VIEWER_ADDRESS -> viewer.clientAddress() != null && viewer.clientPort() > 0
? viewer.clientAddress() + ":" + viewer.clientPort() : null;
case VIEWER_HTTP_VERSION -> viewer.httpVersion();
default -> null;
};
}
/** CloudFront appends the viewer's address to any {@code X-Forwarded-For} the viewer sent. */
private static String forwardedFor(List<String> viewerValues, String clientAddress) {
List<String> values = new ArrayList<>(viewerValues);
if (clientAddress != null && !clientAddress.isBlank()) {
values.add(clientAddress);
}
return values.isEmpty() ? null : String.join(",", values);
}
private static String viewerProtocolVersion(String httpVersion) {
return httpVersion == null || httpVersion.isBlank() ? "1.1" : httpVersion;
}
/** A request identifier shaped like CloudFront's: 40 random bytes, URL-safe Base64. */
private static String requestId() {
byte[] bytes = new byte[40];
ThreadLocalRandom.current().nextBytes(bytes);
return Base64.getUrlEncoder().encodeToString(bytes);
}
private static boolean isTrue(Object value) {
return value instanceof Boolean bool ? bool : value != null && "true".equalsIgnoreCase(value.toString());
}
private static List<String> stringList(Map<?, ?> map, String key) {
Object value = map != null ? map.get(key) : null;
if (!(value instanceof List<?> list)) {
return List.of();
}
List<String> strings = new ArrayList<>(list.size());
for (Object item : list) {
if (item != null) {
strings.add(item.toString());
}
}
return strings;
}
private static String normalize(String name) {
return name == null ? "" : name.trim().toLowerCase(Locale.ROOT);
}
/** The resolved selections of one request, unioned across the policies that apply. */
private record Selections(boolean legacy, List<Selection> headers, boolean legacyDefaults,
List<Selection> cookies, List<Selection> queryStrings,
boolean gzip, boolean brotli, boolean normalizesAcceptEncoding) {
/**
* Whether a viewer header is forwarded: a policy selects it, or legacy settings forward it
* by default.
*/
boolean forwardsHeader(String normalizedName) {
if (headers.stream().anyMatch(selection -> selection.includes(normalizedName))) {
return true;
}
return legacyDefaults && !LEGACY_REMOVED.contains(normalizedName);
}
/**
* Whether CloudFront adds one of its own request headers: a whitelist or
* {@code allViewerAndWhitelistCloudFront} list names it, or {@code allExcept} does not
* exclude it (the managed AllViewerExceptHostHeader policy is documented to add them).
*/
boolean generatesHeader(String normalizedName) {
for (Selection selection : headers) {
boolean generated = switch (selection.behavior()) {
case "whitelist", "allViewerAndWhitelistCloudFront" -> selection.lists(normalizedName);
case "allExcept" -> !selection.lists(normalizedName);
default -> false;
};
if (generated) {
return true;
}
}
return false;
}
}
/**
* One policy's choice of names of one kind: headers, cookies or query strings. Header names match
* case-insensitively, cookie and query string names case-sensitively. Legacy cookie allowlists may
* use {@code *} and {@code ?} wildcards.
*/
private record Selection(String behavior, List<String> names, boolean ignoreCase,
boolean wildcards) {
static Selection of(Object block, String behaviorKey, String listKey, boolean ignoreCase) {
if (!(block instanceof Map<?, ?> map) || map.get(behaviorKey) == null) {
return new Selection("none", List.of(), ignoreCase, false);
}
return new Selection(map.get(behaviorKey).toString(), stringList(map, listKey),
ignoreCase, false);
}
boolean includes(String name) {
return switch (behavior) {
case "whitelist" -> lists(name);
case "all", "allViewer", "allViewerAndWhitelistCloudFront" -> true;
case "allExcept" -> !lists(name);
default -> false;
};
}
boolean lists(String name) {
for (String candidate : names) {
boolean matches = wildcards
? wildcardMatches(candidate, name)
: ignoreCase ? candidate.equalsIgnoreCase(name) : candidate.equals(name);
if (matches) {
return true;
}
}
return false;
}
private static boolean wildcardMatches(String pattern, String name) {
return wildcardMatches(pattern, 0, name, 0);
}
private static boolean wildcardMatches(String pattern, int p, String name, int n) {
if (p == pattern.length()) {
return n == name.length();
}
char c = pattern.charAt(p);
if (c == '*') {
for (int i = n; i <= name.length(); i++) {
if (wildcardMatches(pattern, p + 1, name, i)) {
return true;
}
}
return false;
}
return n < name.length() && (c == '?' || c == name.charAt(n))
&& wildcardMatches(pattern, p + 1, name, n + 1);
}
}
}
@@ -166,6 +166,35 @@ public final class CloudFrontRequestRouter {
return allowedMethodsOrDefault(dflt != null ? dflt.getAllowedMethods() : null);
}
/**
* The settings that decide what the cache behavior serving a normalized path forwards to its
* origin: cache policy, origin request policy, legacy {@code ForwardedValues} and cached methods.
*/
public record BehaviorForwarding(String cachePolicyId, String originRequestPolicyId,
Map<String, Object> forwardedValues,
List<String> cachedMethods) {
}
/** Returns the forwarding settings of the cache behavior that serves a normalized path. */
public static BehaviorForwarding matchForwarding(DistributionConfig config, String normalizedPath) {
List<CacheBehavior> behaviors = config.getCacheBehaviors();
if (behaviors != null) {
for (CacheBehavior behavior : behaviors) {
if (pathPatternMatches(behavior.getPathPattern(), normalizedPath)) {
return new BehaviorForwarding(behavior.getCachePolicyId(),
behavior.getOriginRequestPolicyId(), behavior.getForwardedValues(),
behavior.getCachedMethods());
}
}
}
DefaultCacheBehavior dflt = config.getDefaultCacheBehavior();
if (dflt == null) {
return new BehaviorForwarding(null, null, null, null);
}
return new BehaviorForwarding(dflt.getCachePolicyId(), dflt.getOriginRequestPolicyId(),
dflt.getForwardedValues(), dflt.getCachedMethods());
}
private static List<String> allowedMethodsOrDefault(List<String> methods) {
return methods == null || methods.isEmpty() ? List.of("GET", "HEAD") : methods;
}
@@ -59,6 +59,7 @@ public class CloudFrontService {
Set.of("s3", "mediastore", "mediapackagev2", "lambda");
private static final Set<String> OAC_SIGNING_BEHAVIORS =
Set.of("always", "never", "no-override");
private static final Set<String> FUNCTION_RUNTIMES = Set.of("cloudfront-js-1.0", "cloudfront-js-2.0");
private static final int MAX_ORIGIN_CUSTOM_HEADERS = 30;
private static final int MAX_CUSTOM_HEADER_NAME_LENGTH = 256;
private static final int MAX_CUSTOM_HEADER_VALUE_LENGTH = 1_783;
@@ -83,6 +84,24 @@ public class CloudFrontService {
"60669652-455b-4ae9-85a4-c4c02393f86c";
private static final Map<String, ResponseHeadersPolicy> MANAGED_RESPONSE_HEADERS_POLICIES =
managedResponseHeadersPolicies();
static final String MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID =
"216adef6-5c7f-47e4-b989-5492eafa07d3";
static final String MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID =
"33f36d7e-f396-46d9-90e0-52428a34d9dc";
static final String MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID =
"b689b0a8-53d0-40ab-baf2-68738e2966ac";
static final String MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
"59781a5b-3903-41f3-afcb-af62929ccde1";
static final String MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
"88a5eaf4-2fd4-4709-b370-b4c650ea3fcf";
static final String MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID =
"775133bc-15f2-49f9-abea-afb2e0bf67d2";
static final String MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID =
"bf0718e1-ba1e-49d1-88b1-f726733018ae";
static final String MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID =
"acba4595-bd28-49b8-b9fe-13317c0390fa";
private static final Map<String, OriginRequestPolicy> MANAGED_ORIGIN_REQUEST_POLICIES =
managedOriginRequestPolicies();
private final StorageBackend<String, Distribution> distStore;
private final StorageBackend<String, List<Invalidation>> invalidationStore;
@@ -543,14 +562,19 @@ public class CloudFrontService {
}
public OriginRequestPolicy getOriginRequestPolicy(String id) {
return orpStore.get(id).orElseThrow(() ->
new AwsException("NoSuchOriginRequestPolicy",
return orpStore.get(id)
.or(() -> Optional.ofNullable(MANAGED_ORIGIN_REQUEST_POLICIES.get(id)))
.orElseThrow(() -> new AwsException("NoSuchOriginRequestPolicy",
"The specified origin request policy does not exist.", 404));
}
public synchronized OriginRequestPolicy updateOriginRequestPolicy(String id, String ifMatch,
OriginRequestPolicy updated) {
OriginRequestPolicy existing = getOriginRequestPolicy(id);
if (isManagedOriginRequestPolicy(id)) {
throw new AwsException("IllegalUpdate",
"AWS managed origin request policies cannot be updated.", 400);
}
if (!existing.getEtag().equals(ifMatch)) {
throw new AwsException("InvalidIfMatchVersion",
"The If-Match version is missing or not valid for the resource.", 400);
@@ -564,6 +588,10 @@ public class CloudFrontService {
public synchronized void deleteOriginRequestPolicy(String id, String ifMatch) {
OriginRequestPolicy existing = getOriginRequestPolicy(id);
if (isManagedOriginRequestPolicy(id)) {
throw new AwsException("IllegalDelete",
"AWS managed origin request policies cannot be deleted.", 400);
}
if (!existing.getEtag().equals(ifMatch)) {
throw new AwsException("InvalidIfMatchVersion",
"The If-Match version is missing or not valid for the resource.", 400);
@@ -577,12 +605,34 @@ public class CloudFrontService {
}
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems) {
List<OriginRequestPolicy> all = new ArrayList<>(orpStore.scan(k -> true));
return listOriginRequestPolicies(marker, maxItems, null);
}
/**
* Lists custom and AWS managed origin request policies; {@code type} narrows the list to the
* AWS lowercase {@code managed} or {@code custom} value.
*/
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems, String type) {
if (type != null && !"custom".equals(type) && !"managed".equals(type)) {
throw new AwsException("InvalidArgument",
"Origin request policy Type must be managed or custom.", 400);
}
List<OriginRequestPolicy> all = new ArrayList<>();
if (!"managed".equals(type)) {
all.addAll(orpStore.scan(k -> true));
}
if (!"custom".equals(type)) {
all.addAll(MANAGED_ORIGIN_REQUEST_POLICIES.values());
}
all.sort((a, b) -> a.getName() != null && b.getName() != null
? a.getName().compareTo(b.getName()) : a.getId().compareTo(b.getId()));
return paginate(all, marker, maxItems, OriginRequestPolicy::getId);
}
static boolean isManagedOriginRequestPolicy(String id) {
return MANAGED_ORIGIN_REQUEST_POLICIES.containsKey(id);
}
// ── Response Headers Policies ─────────────────────────────────────────────
public synchronized ResponseHeadersPolicy createResponseHeadersPolicy(ResponseHeadersPolicy policy) {
@@ -840,6 +890,112 @@ public class CloudFrontService {
return Map.copyOf(policies);
}
/**
* The AWS managed origin request policies, with the IDs and settings the CloudFront Developer
* Guide lists under "Use managed origin request policies".
*/
private static Map<String, OriginRequestPolicy> managedOriginRequestPolicies() {
Map<String, OriginRequestPolicy> policies = new LinkedHashMap<>();
policies.put(MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, "Managed-AllViewer",
"Includes all values (headers, cookies, and query strings) from the viewer request",
selection("HeaderBehavior", "allViewer", "Headers", List.of()),
selection("CookieBehavior", "all", "Cookies", List.of()),
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
policies.put(MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
managedOriginRequestPolicy(
MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
"Managed-AllViewerAndCloudFrontHeaders-2022-06",
"Includes all values from the viewer request and all CloudFront headers "
+ "released through June 2022",
selection("HeaderBehavior", "allViewerAndWhitelistCloudFront", "Headers",
List.of("CloudFront-Forwarded-Proto", "CloudFront-Is-Android-Viewer",
"CloudFront-Is-Desktop-Viewer", "CloudFront-Is-IOS-Viewer",
"CloudFront-Is-Mobile-Viewer", "CloudFront-Is-SmartTV-Viewer",
"CloudFront-Is-Tablet-Viewer", "CloudFront-Viewer-Address",
"CloudFront-Viewer-ASN", "CloudFront-Viewer-City",
"CloudFront-Viewer-Country", "CloudFront-Viewer-Country-Name",
"CloudFront-Viewer-Country-Region",
"CloudFront-Viewer-Country-Region-Name",
"CloudFront-Viewer-Http-Version", "CloudFront-Viewer-Latitude",
"CloudFront-Viewer-Longitude", "CloudFront-Viewer-Metro-Code",
"CloudFront-Viewer-Postal-Code", "CloudFront-Viewer-Time-Zone",
"CloudFront-Viewer-TLS")),
selection("CookieBehavior", "all", "Cookies", List.of()),
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
policies.put(MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
managedOriginRequestPolicy(
MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
"Managed-AllViewerExceptHostHeader",
"Includes all values from the viewer request except the Host header",
selection("HeaderBehavior", "allExcept", "Headers", List.of("Host")),
selection("CookieBehavior", "all", "Cookies", List.of()),
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
policies.put(MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-CustomOrigin",
"Includes the header that enables CORS requests when the origin is a custom origin",
selection("HeaderBehavior", "whitelist", "Headers", List.of("Origin")),
selection("CookieBehavior", "none", "Cookies", List.of()),
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
policies.put(MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-S3Origin",
"Includes the headers that enable CORS requests when the origin is an Amazon S3 bucket",
selection("HeaderBehavior", "whitelist", "Headers", List.of(
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method")),
selection("CookieBehavior", "none", "Cookies", List.of()),
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
policies.put(MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID,
"Managed-Elemental-MediaTailor-PersonalizedManifests",
"For use with an origin that is an AWS Elemental MediaTailor endpoint",
selection("HeaderBehavior", "whitelist", "Headers", List.of(
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method",
"User-Agent", "X-Forwarded-For")),
selection("CookieBehavior", "none", "Cookies", List.of()),
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
policies.put(MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, "Managed-HostHeaderOnly",
"Includes only the Host header from the viewer request",
selection("HeaderBehavior", "whitelist", "Headers", List.of("Host")),
selection("CookieBehavior", "none", "Cookies", List.of()),
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
policies.put(MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, "Managed-UserAgentRefererHeaders",
"Includes only the User-Agent and Referer headers",
selection("HeaderBehavior", "whitelist", "Headers", List.of("User-Agent", "Referer")),
selection("CookieBehavior", "none", "Cookies", List.of()),
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
return Map.copyOf(policies);
}
private static OriginRequestPolicy managedOriginRequestPolicy(
String id, String name, String comment, Map<String, Object> headers,
Map<String, Object> cookies, Map<String, Object> queryStrings) {
Map<String, Object> config = new LinkedHashMap<>();
config.put("HeadersConfig", headers);
config.put("CookiesConfig", cookies);
config.put("QueryStringsConfig", queryStrings);
OriginRequestPolicy policy = new OriginRequestPolicy();
policy.setId(id);
policy.setName(name);
policy.setComment(comment);
policy.setEtag("E23ZP02F085DFQ");
policy.setLastModifiedTime(Instant.EPOCH);
policy.setConfig(Map.copyOf(config));
return policy;
}
/** One policy selection block, in the shape {@link CloudFrontPolicyConfigCodec} stores. */
private static Map<String, Object> selection(String behaviorKey, String behavior,
String listKey, List<String> names) {
Map<String, Object> block = new LinkedHashMap<>();
block.put(behaviorKey, behavior);
if (!names.isEmpty()) {
block.put(listKey, List.copyOf(names));
}
return Map.copyOf(block);
}
private static ResponseHeadersPolicy managedResponseHeadersPolicy(
String id, String name, String comment, Map<String, Object> config) {
ResponseHeadersPolicy policy = new ResponseHeadersPolicy();
@@ -1072,6 +1228,7 @@ public class CloudFrontService {
// ── CloudFront Functions ──────────────────────────────────────────────────
public synchronized CloudFrontFunction createFunction(CloudFrontFunction fn) {
validateFunctionRuntime(fn.getRuntime());
fn.setStage("DEVELOPMENT");
fn.setStatus("UNPUBLISHED");
fn.setEtag(UUID.randomUUID().toString());
@@ -1096,6 +1253,7 @@ public class CloudFrontService {
public synchronized CloudFrontFunction updateFunction(String name, String ifMatch,
CloudFrontFunction updated) {
validateFunctionRuntime(updated.getRuntime());
CloudFrontFunction existing = describeFunction(name, null);
if (!existing.getEtag().equals(ifMatch)) {
throw new AwsException("InvalidIfMatchVersion",
@@ -1168,6 +1326,13 @@ public class CloudFrontService {
return effectiveStage;
}
private static void validateFunctionRuntime(String runtime) {
if (runtime != null && !FUNCTION_RUNTIMES.contains(runtime)) {
throw new AwsException("InvalidArgument",
"The parameter Runtime must be cloudfront-js-1.0 or cloudfront-js-2.0.", 400);
}
}
private static AwsException invalidFunctionStage(String stage) {
return new AwsException("InvalidArgument",
"The parameter Stage must be DEVELOPMENT or LIVE: " + stage, 400);
@@ -10,24 +10,36 @@ import io.github.hectorvent.floci.services.cloudfront.model.OriginAccessControl;
import io.github.hectorvent.floci.services.s3.S3Service;
import io.github.hectorvent.floci.services.s3.model.S3Object;
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpVersion;
import io.vertx.core.net.SocketAddress;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HEAD;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.server.core.CurrentRequestManager;
import java.io.IOException;
import java.io.InputStream;
import java.io.PushbackInputStream;
import java.net.IDN;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
@@ -53,14 +65,19 @@ import java.util.Set;
* <ul>
* <li>S3 origins are read in-process through {@link S3Service} (the bucket is derived from the
* origin domain name).</li>
* <li>Custom origins are fetched over HTTP(S) with {@link java.net.http.HttpClient}.</li>
* <li>Custom origins are fetched over HTTP(S) with {@link CloudFrontOriginHttpClient}, carrying the
* viewer headers, cookies and query strings the matched behavior forwards, as decided by
* {@link CloudFrontOriginRequestBuilder}.</li>
* <li>When an origin returns an error status, a matching {@code CustomErrorResponse} is applied —
* most importantly the single-page-app fallback that rewrites 403/404 to 200 {@code /index.html}.
* If the configured error page is itself missing, the received status is returned (no loop).</li>
* </ul>
*
* <p>Viewer-protocol-policy enforcement is intentionally out of scope for this layer: the emulator
* is HTTP-first. GET/HEAD are served, and OPTIONS is served only when the matched behavior allows it.
* is HTTP-first. Every CloudFront viewer method (GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE) is
* served when the matched behavior's {@code AllowedMethods} includes it. POST, PUT, PATCH and DELETE
* are forwarded to custom origins with the viewer request body and {@code Authorization} header; they
* are not forwarded to in-process S3 origins.
*/
@Path("/_cloudfront/{distId}")
public class CloudFrontServingController {
@@ -80,6 +97,8 @@ public class CloudFrontServingController {
private static final java.util.Set<String> CLOUDFRONT_SIGNING_PARAMS =
java.util.Set.of("Expires", "Signature", "Key-Pair-Id", "Policy", "Hash-Algorithm");
/** Viewer methods that can carry a request body and that CloudFront never caches. */
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private final CloudFrontService service;
private final S3Service s3Service;
@@ -117,7 +136,7 @@ public class CloudFrontServingController {
RequestHost.of(request),
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
request.getHeader("Origin"), "GET", null, null,
request.getHeader("Pragma"));
request.getHeader("Pragma"), null);
}
@HEAD
@@ -131,7 +150,7 @@ public class CloudFrontServingController {
RequestHost.of(request),
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
request.getHeader("Origin"), "HEAD", null, null,
request.getHeader("Pragma"));
request.getHeader("Pragma"), null);
}
@OPTIONS
@@ -147,7 +166,86 @@ public class CloudFrontServingController {
request.getHeader("Origin"), "OPTIONS",
request.getHeader("Access-Control-Request-Method"),
request.getHeader("Access-Control-Request-Headers"),
request.getHeader("Pragma"));
request.getHeader("Pragma"), null);
}
@POST
@Path("/{proxy:.*}")
@Consumes(MediaType.WILDCARD)
public Response post(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
return serveWithBody(distId, "POST", headers, uriInfo, body);
}
@PUT
@Path("/{proxy:.*}")
@Consumes(MediaType.WILDCARD)
public Response put(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
return serveWithBody(distId, "PUT", headers, uriInfo, body);
}
@PATCH
@Path("/{proxy:.*}")
@Consumes(MediaType.WILDCARD)
public Response patch(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
return serveWithBody(distId, "PATCH", headers, uriInfo, body);
}
@DELETE
@Path("/{proxy:.*}")
@Consumes(MediaType.WILDCARD)
public Response delete(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
return serveWithBody(distId, "DELETE", headers, uriInfo, body);
}
private Response serveWithBody(String distId, String method, HttpHeaders headers,
UriInfo uriInfo, InputStream body) {
HttpServerRequest request = currentVertxRequest.getCurrent().request();
String rawViewerPath = rawViewerPath(request.uri());
return serve(distId, rawViewerPath, decodedViewerPath(rawViewerPath),
uriInfo.getRequestUri().getScheme(),
RequestHost.of(request),
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
request.getHeader("Origin"), method, null, null,
request.getHeader("Pragma"),
viewerBody(body, headers.getHeaderString("Transfer-Encoding"),
headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)));
}
/**
* The viewer's request body and its length. It is {@code -1} when the body comes chunked, which
* takes precedence over any {@code Content-Length} as HTTP has it, and otherwise the
* {@code Content-Length}. With neither header an HTTP/1.1 request has no body, but an HTTP/2 one
* can still send one, so the body is looked at: one that is already at its end is empty, and one
* that is not is forwarded chunked.
*/
static ViewerBody viewerBody(InputStream body, String transferEncoding, String contentLength) {
InputStream stream = body != null ? body : InputStream.nullInputStream();
if (transferEncoding != null) {
return new ViewerBody(stream, -1);
}
if (contentLength != null && !contentLength.isBlank()) {
try {
return new ViewerBody(stream, Long.parseLong(contentLength.trim()));
} catch (NumberFormatException e) {
LOG.debugv("Treating an invalid viewer Content-Length as chunked: {0}", e.getMessage());
return new ViewerBody(stream, -1);
}
}
PushbackInputStream peekable = new PushbackInputStream(stream, 1);
try {
int first = peekable.read();
if (first < 0) {
return new ViewerBody(InputStream.nullInputStream(), 0);
}
peekable.unread(first);
} catch (IOException e) {
LOG.debugv("Could not read the start of a viewer request body: {0}", e.getMessage());
}
return new ViewerBody(peekable, -1);
}
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
@@ -156,7 +254,8 @@ public class CloudFrontServingController {
String viewerOrigin, String method,
String accessControlRequestMethod,
String accessControlRequestHeaders,
String pragma) {
String pragma,
ViewerBody viewerBody) {
boolean includeBody = !"HEAD".equals(method);
boolean preflightRequest = "OPTIONS".equals(method)
&& viewerOrigin != null && !viewerOrigin.isBlank()
@@ -199,11 +298,17 @@ public class CloudFrontServingController {
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
viewerScheme, viewerAuthorization, method, viewerOrigin,
accessControlRequestMethod, accessControlRequestHeaders);
accessControlRequestMethod, accessControlRequestHeaders, viewerBody);
if (origin.status() >= 400) {
Response fallback = applyCustomError(
dist, origin, viewerScheme, viewerAuthorization, includeBody, directives);
Response fallback;
try {
fallback = applyCustomError(
dist, origin, viewerScheme, viewerAuthorization, includeBody, directives);
} catch (RuntimeException e) {
origin.closeStream();
throw e;
}
if (fallback != null) {
return fallback;
}
@@ -345,7 +450,8 @@ public class CloudFrontServingController {
String viewerScheme, String viewerAuthorization,
String method, String viewerOrigin,
String accessControlRequestMethod,
String accessControlRequestHeaders) {
String accessControlRequestHeaders,
ViewerBody viewerBody) {
DistributionConfig config = distribution.getConfig();
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
@@ -357,6 +463,14 @@ public class CloudFrontServingController {
return fetchS3Preflight(origin, viewerOrigin, accessControlRequestMethod,
accessControlRequestHeaders);
}
if (BODY_METHODS.contains(method)) {
// AWS forwards these methods and S3 evaluates them against the bucket policy (OAC
// supports PUT and DELETE). The in-process origin authorizes reads only, so a write is
// answered as S3 answers a request that holds no write grant.
LOG.debugv("CloudFront does not forward {0} requests to in-process S3 origin {1}",
method, origin.getId());
return OriginResponse.error(403, "Access Denied");
}
String key = CloudFrontRequestRouter.resolveOriginKey(
origin.getOriginPath(), decodedViewerPath, config.getDefaultRootObject());
return fetchFromS3(
@@ -364,11 +478,86 @@ public class CloudFrontServingController {
}
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(
origin.getOriginPath(), rawViewerPath, config.getDefaultRootObject());
// CloudFront forwards viewer query strings only when selected by a cache policy,
// origin request policy, or legacy ForwardedValues configuration. Those policy
// semantics are not modeled in the data plane yet, so the AWS default is to omit them.
return fetchFromCustomOrigin(origin, forwardUri, null, viewerScheme, method,
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders);
CloudFrontOriginRequestBuilder.OriginRequest originRequest =
CloudFrontOriginRequestBuilder.build(viewerRequest(method, viewerScheme),
forwarding(config, normalized), distribution.getDomainName());
return fetchFromCustomOrigin(
origin, forwardUri, originRequest, viewerScheme, method, viewerBody);
}
/** The live viewer request, with CloudFront's signing parameters removed from its query. */
private CloudFrontOriginRequestBuilder.ViewerRequest viewerRequest(
String method, String viewerScheme) {
HttpServerRequest request = currentVertxRequest.getCurrent().request();
List<CloudFrontOriginRequestBuilder.Header> headers = new ArrayList<>();
for (Map.Entry<String, String> header : request.headers()) {
headers.add(new CloudFrontOriginRequestBuilder.Header(header.getKey(), header.getValue()));
}
String appQuery = stripCloudFrontSigningParams(request.query());
SocketAddress client = request.remoteAddress();
return new CloudFrontOriginRequestBuilder.ViewerRequest(method, headers,
appQuery.isEmpty() ? null : appQuery,
client != null ? client.hostAddress() : null,
client != null ? client.port() : -1,
viewerScheme, httpVersion(request.version()));
}
private static String httpVersion(HttpVersion version) {
if (version == null) {
return null;
}
return switch (version) {
case HTTP_1_0 -> "1.0";
case HTTP_1_1 -> "1.1";
case HTTP_2 -> "2.0";
};
}
/**
* Resolves what the matched behavior forwards: legacy {@code ForwardedValues} when it has no cache
* policy, otherwise its cache policy parameters and origin request policy.
*/
private CloudFrontOriginRequestBuilder.Forwarding forwarding(
DistributionConfig config, String normalized) {
CloudFrontRequestRouter.BehaviorForwarding behavior =
CloudFrontRequestRouter.matchForwarding(config, normalized);
boolean optionsCached = behavior.cachedMethods() != null
&& behavior.cachedMethods().contains("OPTIONS");
if (behavior.cachePolicyId() == null || behavior.cachePolicyId().isBlank()) {
return CloudFrontOriginRequestBuilder.Forwarding.legacy(
behavior.forwardedValues(), optionsCached);
}
return CloudFrontOriginRequestBuilder.Forwarding.policies(
cachePolicyParameters(behavior.cachePolicyId()),
originRequestPolicyConfig(behavior.originRequestPolicyId()), optionsCached);
}
private Map<?, ?> cachePolicyParameters(String cachePolicyId) {
try {
Map<String, Object> config = service.getCachePolicy(cachePolicyId).getConfig();
return config != null
&& config.get("ParametersInCacheKeyAndForwardedToOrigin") instanceof Map<?, ?> parameters
? parameters : null;
} catch (AwsException e) {
// AWS managed cache policies are not modeled. The common ones (CachingDisabled,
// CachingOptimized) forward no viewer values, which is what an unknown id contributes.
LOG.debugv("Cache policy {0} is not defined; it forwards no viewer values", cachePolicyId);
return null;
}
}
private Map<?, ?> originRequestPolicyConfig(String originRequestPolicyId) {
if (originRequestPolicyId == null || originRequestPolicyId.isBlank()) {
return null;
}
try {
return service.getOriginRequestPolicy(originRequestPolicyId).getConfig();
} catch (AwsException e) {
// Distribution writes do not validate this reference, so keep serving without the policy
// but make the dangling id visible.
LOG.warnv("Distribution references missing origin request policy {0}", originRequestPolicyId);
return null;
}
}
private OriginResponse fetchFromS3(
@@ -386,10 +575,12 @@ public class CloudFrontServingController {
authorizeS3OriginRead(
distribution, origin, bucket, key, viewerAuthorization);
if (includeBody) {
S3Object obj = s3Service.getObject(bucket, key);
byte[] data = obj.getData() != null ? obj.getData() : new byte[0];
// The body is streamed to the viewer, so an object of any size is served without
// being read into the heap; the metadata and the stream come from one snapshot.
S3Service.ObjectRead read = s3Service.openObject(bucket, key, null);
S3Object obj = read.object();
response = new OriginResponse(
200, contentType(obj), data, data.length, s3ObjectHeaders(obj));
200, contentType(obj), null, read.body(), obj.getSize(), s3ObjectHeaders(obj));
} else {
S3Object meta = s3Service.headObject(bucket, key);
response = new OriginResponse(
@@ -398,8 +589,13 @@ public class CloudFrontServingController {
} catch (AwsException e) {
response = OriginResponse.error(e.getHttpStatus(), e.getMessage());
}
return withS3CorsHeaders(
bucket, origin, includeBody ? "GET" : "HEAD", response);
try {
return withS3CorsHeaders(
bucket, origin, includeBody ? "GET" : "HEAD", response);
} catch (RuntimeException e) {
response.closeStream();
throw e;
}
}
private void authorizeS3OriginRead(
@@ -490,15 +686,17 @@ public class CloudFrontServingController {
"This CORS request is not allowed."));
}
/** Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path. */
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri, String rawQuery,
String viewerScheme, String method, String viewerOrigin,
String accessControlRequestMethod,
String accessControlRequestHeaders) {
/**
* Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path;
* {@code originRequest} carries the forwarded query and headers.
*/
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri,
CloudFrontOriginRequestBuilder.OriginRequest originRequest,
String viewerScheme, String method, ViewerBody viewerBody) {
boolean includeBody = !"HEAD".equals(method);
try {
URI target = buildCustomOriginUri(
origin, viewerScheme, forwardUri, rawQuery);
origin, viewerScheme, forwardUri, originRequest.rawQuery());
HttpRequest.Builder rb = HttpRequest.newBuilder()
.uri(target)
.timeout(Duration.ofSeconds(30));
@@ -517,18 +715,19 @@ public class CloudFrontServingController {
}
}
rb.method(method, HttpRequest.BodyPublishers.noBody());
if ("OPTIONS".equals(method)) {
addRequestHeader(rb, "Origin", viewerOrigin);
addRequestHeader(rb, "Access-Control-Request-Method", accessControlRequestMethod);
addRequestHeader(rb, "Access-Control-Request-Headers", accessControlRequestHeaders);
}
HttpResponse<byte[]> resp = httpClient.send(
rb.build(), originHeaders, HttpResponse.BodyHandlers.ofByteArray());
// Both bodies are streamed, so neither has to fit in memory: the viewer's goes out with its
// own Content-Length, or chunked as it came, and the origin's comes back as it arrives.
ViewerBody requestBody = originRequestBody(method, viewerBody);
CloudFrontOriginHttpClient.OpenResponse resp = httpClient.open(rb.build(), originRequest.headers(),
originHeaders, requestBody != null ? requestBody.stream() : null,
requestBody != null ? requestBody.length() : -1);
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
byte[] body = resp.body() != null ? resp.body() : new byte[0];
long contentLength = includeBody ? body.length : responseContentLength(resp);
return new OriginResponse(resp.statusCode(), ct, includeBody ? body : null,
contentLength, resp.headers().map());
long contentLength = resp.contentLength();
if (!includeBody) {
resp.body().close();
return new OriginResponse(resp.statusCode(), ct, null, contentLength, resp.headers().map());
}
return new OriginResponse(resp.statusCode(), ct, null, resp.body(), contentLength, resp.headers().map());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warnv("CloudFront custom origin fetch was interrupted for host {0}",
@@ -542,6 +741,24 @@ public class CloudFrontServingController {
}
}
/**
* The body CloudFront sends to the origin: POST, PUT and PATCH always carry one (possibly empty, so
* the origin still sees {@code Content-Length: 0}); DELETE carries one only when the viewer sent it.
*/
private static ViewerBody originRequestBody(String method, ViewerBody viewerBody) {
if (viewerBody == null || !BODY_METHODS.contains(method)) {
return null;
}
if ("DELETE".equals(method) && viewerBody.length() == 0) {
return null;
}
return viewerBody;
}
/** A viewer request body, read as it is forwarded, and its length, {@code -1} when it comes chunked. */
record ViewerBody(InputStream stream, long length) {
}
private static String originAccessIdentityId(Origin origin) {
Map<String, String> config = origin.getS3OriginConfig();
String value = config != null ? config.get("OriginAccessIdentity") : null;
@@ -639,15 +856,6 @@ public class CloudFrontServingController {
return normalized.toLowerCase(Locale.ROOT);
}
private static long responseContentLength(HttpResponse<?> response) {
try {
return response.headers().firstValueAsLong("content-length").orElse(-1L);
} catch (NumberFormatException e) {
LOG.debugv("Ignoring invalid custom-origin Content-Length: {0}", e.getMessage());
return -1L;
}
}
private static String safeOriginName(Origin origin) {
try {
return normalizeOriginHost(origin.getDomainName());
@@ -688,8 +896,7 @@ public class CloudFrontServingController {
String pagePath = str(cer.get("ResponsePagePath"));
if (pagePath.isBlank()) {
// ResponseCode override with no custom page: keep the origin body, change the status.
return toResponse(new OriginResponse(responseCode, origin.contentType(), origin.body(),
origin.contentLength(), origin.headers()), includeBody, directives);
return toResponse(origin.withStatus(responseCode), includeBody, directives);
}
String errNormalized = CloudFrontRequestRouter.normalizePath(pagePath);
@@ -699,6 +906,8 @@ public class CloudFrontServingController {
return null;
}
// The custom error page replaces the origin's error response, which is not sent.
origin.closeStream();
OriginResponse page;
if (CloudFrontRequestRouter.isS3Origin(errOrigin)) {
String key = CloudFrontRequestRouter.resolveOriginKey(errOrigin.getOriginPath(), errNormalized, null);
@@ -707,16 +916,15 @@ public class CloudFrontServingController {
} else {
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
page = fetchFromCustomOrigin(
errOrigin, forwardUri, null, viewerScheme,
includeBody ? "GET" : "HEAD", null, null, null);
errOrigin, forwardUri, CloudFrontOriginRequestBuilder.OriginRequest.empty(),
viewerScheme, includeBody ? "GET" : "HEAD", null);
}
if (page.status() >= 400) {
// Custom error page unavailable → return the status received from the error-page origin
// (AWS behavior), without recursively applying custom error handling.
return toResponse(page, includeBody, directives);
}
return toResponse(new OriginResponse(responseCode, page.contentType(), page.body(),
page.contentLength(), page.headers()), includeBody, directives);
return toResponse(page.withStatus(responseCode), includeBody, directives);
}
private Map<String, Object> matchCustomError(DistributionConfig config, int status) {
@@ -734,21 +942,43 @@ public class CloudFrontServingController {
private Response toResponse(OriginResponse origin, boolean includeBody,
ResponseHeadersPolicyConfigCodec.Directives directives) {
Response.ResponseBuilder rb = Response.status(origin.status());
HeaderCollection collected = collectResponseHeaders(origin, includeBody);
applyResponseHeadersPolicy(collected.headers(), directives, collected.forbiddenPolicyHeaders());
for (HeaderValues header : collected.headers().values()) {
if ("date".equalsIgnoreCase(header.name())) {
currentVertxRequest.getCurrent().response()
.putHeader(header.name(), header.values());
} else {
header.values().forEach(value -> rb.header(header.name(), value));
try {
Response.ResponseBuilder rb = Response.status(origin.status());
HeaderCollection collected = collectResponseHeaders(origin, includeBody);
applyResponseHeadersPolicy(collected.headers(), directives, collected.forbiddenPolicyHeaders());
for (HeaderValues header : collected.headers().values()) {
if ("date".equalsIgnoreCase(header.name())) {
currentVertxRequest.getCurrent().response()
.putHeader(header.name(), header.values());
} else {
header.values().forEach(value -> rb.header(header.name(), value));
}
}
if (includeBody && origin.stream() != null) {
// A disconnected viewer can skip StreamingOutput entirely, so its finally block
// alone cannot release the origin connection.
CurrentRequestManager.get().registerCompletionCallback(ignored -> origin.closeStream());
InputStream body = origin.stream();
StreamingOutput entity = output -> {
try (InputStream in = body) {
in.transferTo(output);
}
};
rb.entity(entity);
} else {
if (origin.stream() != null) {
// A body that will not be sent, as for a HEAD, still holds its source open.
origin.closeStream();
}
if (includeBody && origin.body() != null) {
rb.entity(origin.body());
}
}
return rb.build();
} catch (RuntimeException e) {
origin.closeStream();
throw e;
}
if (includeBody && origin.body() != null) {
rb.entity(origin.body());
}
return rb.build();
}
/**
@@ -784,7 +1014,8 @@ public class CloudFrontServingController {
if (origin.contentType() != null) {
setHeader(headers, "Content-Type", origin.contentType());
}
if (!includeBody && origin.contentLength() >= 0) {
// A byte body sets its own length; a HEAD response and a streamed body need it stated.
if ((!includeBody || origin.stream() != null) && origin.contentLength() >= 0) {
setHeader(headers, "Content-Length", Long.toString(origin.contentLength()));
}
return new HeaderCollection(headers, forbiddenPolicyHeaders);
@@ -935,12 +1166,7 @@ public class CloudFrontServingController {
"Access-Control-Request-Method"));
}
});
return new OriginResponse(
response.status(),
response.contentType(),
response.body(),
response.contentLength(),
headers);
return response.withHeaders(headers);
}
private static String originCustomHeader(Origin origin, String requestedName) {
@@ -1006,12 +1232,6 @@ public class CloudFrontServingController {
return values;
}
private static void addRequestHeader(HttpRequest.Builder request, String name, String value) {
if (value != null && !value.isBlank()) {
request.header(name, value);
}
}
private static String str(Object value) {
return value != null ? value.toString() : "";
}
@@ -1039,12 +1259,42 @@ public class CloudFrontServingController {
Set<String> forbiddenPolicyHeaders) {
}
/** The result of fetching from an origin, including end-to-end response headers. */
private record OriginResponse(int status, String contentType, byte[] body, long contentLength,
Map<String, List<String>> headers) {
/**
* The result of fetching from an origin, including end-to-end response headers. An origin's
* content, from an S3 object or a custom origin, is an open {@code stream} instead of
* {@code body}, so it never passes through the heap; the viewer response writes and closes it.
* {@code body} holds the bytes of a response Floci makes itself, such as an error.
*/
private record OriginResponse(int status, String contentType, byte[] body, InputStream stream,
long contentLength, Map<String, List<String>> headers) {
OriginResponse(int status, String contentType, byte[] body, long contentLength,
Map<String, List<String>> headers) {
this(status, contentType, body, null, contentLength, headers);
}
static OriginResponse error(int status, String message) {
byte[] b = message == null ? new byte[0] : message.getBytes(StandardCharsets.UTF_8);
return new OriginResponse(status, MediaType.TEXT_PLAIN, b, b.length, Map.of());
}
OriginResponse withStatus(int newStatus) {
return new OriginResponse(newStatus, contentType, body, stream, contentLength, headers);
}
OriginResponse withHeaders(Map<String, List<String>> newHeaders) {
return new OriginResponse(status, contentType, body, stream, contentLength, newHeaders);
}
/** Closes the stream of a response that will not be written. */
void closeStream() {
if (stream == null) {
return;
}
try {
stream.close();
} catch (IOException e) {
LOG.debugv("Could not close an unsent origin body: {0}", e.getMessage());
}
}
}
}
@@ -179,29 +179,82 @@ public class CloudWatchDashboardsService {
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
return findByArn(resourceArn, region).map(Dashboard::getTags).orElse(Map.of());
return requireDashboard(resourceArn, region).getTags();
}
public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> {
dashboard.getTags().putAll(tags);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
});
Dashboard dashboard = requireDashboard(resourceArn, region);
dashboard.getTags().putAll(tags);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
}
public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> {
tagKeys.forEach(dashboard.getTags()::remove);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
});
Dashboard dashboard = requireDashboard(resourceArn, region);
tagKeys.forEach(dashboard.getTags()::remove);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
}
/**
* Resolves the dashboard an ARN names, or reports that nothing does. The tag operations
* declare {@code ResourceNotFoundException}, which is a different shape from the
* {@code ResourceNotFound} that {@code GetDashboard} declares,
* so this is not the {@link #notFound(String)} used by the rest of this service.
*/
private Dashboard requireDashboard(String resourceArn, String region) {
return findByArn(resourceArn, region).orElseThrow(() ->
new AwsException("ResourceNotFoundException",
"Dashboard does not exist: " + resourceArn, 404));
}
private java.util.Optional<Dashboard> findByArn(String resourceArn, String region) {
return dashboardStore.scan(k -> k.startsWith(region + "::")).stream()
.filter(d -> d.getDashboardArn() != null && d.getDashboardArn().equals(resourceArn))
.filter(d -> namesDashboard(resourceArn, d.getDashboardArn()))
.findFirst();
}
/**
* Whether {@code candidate} is an ARN for the dashboard whose own ARN is {@code stored}.
*
* <p>Both the region-ful and the regionless form resolve, deliberately, because the two
* disagree here and a caller may hold either. Dashboards are a global resource and AWS
* documents their ARN without a region on both the TagResource and the ListTagsForResource
* pages, {@code arn:aws:cloudwatch::<account-id>:dashboard/<dashboard-name>}, while Floci
* mints a region-ful one. That divergence used to be invisible on this path: an ARN
* matching nothing was a silent success answering an empty tag map. Now that it is an
* error, a caller sending the documented ARN for a dashboard that exists would be told the
* dashboard does not, so the lookup accepts both shapes.
*
* <p>What Floci mints is left alone here on purpose. Clients may already hold the ARNs it
* has handed out, so bringing the minted shape into line with AWS is a separate change
* from making the lookup tolerant, and only the second one is needed to keep this path
* correct.
*
* <p>Only an absent region is accepted in place of the dashboard's own, so an ARN naming
* some other region is still a miss rather than resolving to a same-named dashboard
* elsewhere.
*/
private static boolean namesDashboard(String candidate, String stored) {
if (candidate == null || stored == null) {
return false;
}
return candidate.equals(stored) || candidate.equals(withoutRegion(stored));
}
/**
* Blanks the region field of an ARN, turning
* {@code arn:aws:cloudwatch:us-east-1:123:dashboard/x} into
* {@code arn:aws:cloudwatch::123:dashboard/x}. An ARN with fewer than six fields is
* returned unchanged, since there is no region field to blank.
*/
private static String withoutRegion(String arn) {
String[] fields = arn.split(":", 6);
if (fields.length < 6) {
return arn;
}
fields[3] = "";
return String.join(":", fields);
}
private static String key(String region, String dashboardName) {
return region + "::" + dashboardName;
}
@@ -275,9 +275,10 @@ public class AlarmEvaluator {
private static boolean isEvaluable(MetricAlarm alarm) {
return alarm.getRegion() != null
&& alarm.getMetrics().isEmpty()
&& alarm.getNamespace() != null && !alarm.getNamespace().isBlank()
&& alarm.getMetricName() != null && !alarm.getMetricName().isBlank()
&& alarm.getPeriod() > 0
&& alarm.getPeriod() != null && alarm.getPeriod() > 0
&& alarm.getEvaluationPeriods() > 0
&& alarm.getComparisonOperator() != null;
}
@@ -0,0 +1,155 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.sns.SnsService;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Publishes SNS actions once per state transition, independently of scaling action retries. */
@ApplicationScoped
public class AlarmSnsNotifier {
/** The names CloudWatch writes in the alarm message's Region field; other regions use their ID. */
private static final Map<String, String> REGION_NAMES = Map.ofEntries(
Map.entry("us-east-1", "US East (N. Virginia)"),
Map.entry("us-east-2", "US East (Ohio)"),
Map.entry("us-west-1", "US West (N. California)"),
Map.entry("us-west-2", "US West (Oregon)"),
Map.entry("ca-central-1", "Canada (Central)"),
Map.entry("ca-west-1", "Canada West (Calgary)"),
Map.entry("sa-east-1", "South America (Sao Paulo)"),
Map.entry("eu-west-1", "EU (Ireland)"),
Map.entry("eu-west-2", "EU (London)"),
Map.entry("eu-west-3", "EU (Paris)"),
Map.entry("eu-central-1", "EU (Frankfurt)"),
Map.entry("eu-central-2", "EU (Zurich)"),
Map.entry("eu-north-1", "EU (Stockholm)"),
Map.entry("eu-south-1", "EU (Milan)"),
Map.entry("eu-south-2", "EU (Spain)"),
Map.entry("me-south-1", "Middle East (Bahrain)"),
Map.entry("me-central-1", "Middle East (UAE)"),
Map.entry("il-central-1", "Israel (Tel Aviv)"),
Map.entry("af-south-1", "Africa (Cape Town)"),
Map.entry("ap-east-1", "Asia Pacific (Hong Kong)"),
Map.entry("ap-south-1", "Asia Pacific (Mumbai)"),
Map.entry("ap-south-2", "Asia Pacific (Hyderabad)"),
Map.entry("ap-northeast-1", "Asia Pacific (Tokyo)"),
Map.entry("ap-northeast-2", "Asia Pacific (Seoul)"),
Map.entry("ap-northeast-3", "Asia Pacific (Osaka)"),
Map.entry("ap-southeast-1", "Asia Pacific (Singapore)"),
Map.entry("ap-southeast-2", "Asia Pacific (Sydney)"),
Map.entry("ap-southeast-3", "Asia Pacific (Jakarta)"),
Map.entry("ap-southeast-4", "Asia Pacific (Melbourne)"));
private static final Logger LOG = Logger.getLogger(AlarmSnsNotifier.class);
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT).withZone(ZoneOffset.UTC);
private final SnsService snsService;
private final ObjectMapper objectMapper;
public AlarmSnsNotifier(SnsService snsService, ObjectMapper objectMapper) {
this.snsService = snsService;
this.objectMapper = objectMapper;
}
record Notification(String topicArn, String message, String subject, String region) {}
List<Notification> prepareTransition(MetricAlarm alarm, String oldState) {
if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) {
return List.of();
}
List<String> actions = switch (alarm.getStateValue()) {
case "ALARM" -> alarm.getAlarmActions();
case "OK" -> alarm.getOkActions();
case "INSUFFICIENT_DATA" -> alarm.getInsufficientDataActions();
default -> List.of();
};
if (actions == null) {
return List.of();
}
List<Notification> notifications = new ArrayList<>();
for (String action : actions) {
if (!AwsArnUtils.isArnFor(action, "sns")) {
continue;
}
try {
AwsArnUtils.Arn topic = AwsArnUtils.parse(action);
String subject = alarm.getStateValue() + ": \"" + alarm.getAlarmName() + "\"";
if (subject.length() > 100) {
subject = subject.substring(0, 100);
}
notifications.add(new Notification(action, message(alarm, oldState), subject, topic.region()));
} catch (Exception e) {
LOG.warnv(e, "Unable to publish alarm {0} state {1} to SNS action {2}",
alarm.getAlarmName(), alarm.getStateValue(), action);
}
}
return notifications;
}
void publish(List<Notification> notifications) {
for (Notification notification : notifications) {
try {
snsService.publish(notification.topicArn(), null, notification.message(),
notification.subject(), notification.region());
} catch (Exception error) {
LOG.warnv(error, "Unable to publish alarm transition {0} to SNS action {1}",
notification.subject(), notification.topicArn());
}
}
}
private String message(MetricAlarm alarm, String oldState) throws JsonProcessingException {
AwsArnUtils.Arn arn = AwsArnUtils.parse(alarm.getAlarmArn());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("AlarmName", alarm.getAlarmName());
payload.put("AlarmDescription", alarm.getAlarmDescription());
payload.put("AWSAccountId", arn.accountId());
payload.put("AlarmConfigurationUpdatedTimestamp", timestamp(alarm.getAlarmConfigurationUpdatedTimestamp()));
payload.put("NewStateValue", alarm.getStateValue());
payload.put("NewStateReason", alarm.getStateReason());
payload.put("StateChangeTime", timestamp(alarm.getStateUpdatedTimestamp()));
payload.put("Region", REGION_NAMES.getOrDefault(arn.region(), arn.region()));
payload.put("AlarmArn", alarm.getAlarmArn());
payload.put("OldStateValue", oldState);
payload.put("OKActions", alarm.getOkActions());
payload.put("AlarmActions", alarm.getAlarmActions());
payload.put("InsufficientDataActions", alarm.getInsufficientDataActions());
Map<String, Object> trigger = new LinkedHashMap<>();
trigger.put("MetricName", alarm.getMetricName());
trigger.put("Namespace", alarm.getNamespace());
trigger.put("StatisticType", "Statistic");
trigger.put("Statistic", alarm.getStatistic() != null
? alarm.getStatistic().toUpperCase(Locale.ROOT) : null);
trigger.put("Unit", alarm.getUnit());
trigger.put("Dimensions", alarm.getDimensions());
trigger.put("Period", alarm.getPeriod());
trigger.put("EvaluationPeriods", alarm.getEvaluationPeriods());
if (alarm.getDatapointsToAlarm() != null) {
trigger.put("DatapointsToAlarm", alarm.getDatapointsToAlarm());
}
trigger.put("ComparisonOperator", alarm.getComparisonOperator());
trigger.put("Threshold", alarm.getThreshold());
trigger.put("TreatMissingData", alarm.getTreatMissingData());
trigger.put("EvaluateLowSampleCountPercentile", alarm.getEvaluateLowSampleCountPercentile() != null
? alarm.getEvaluateLowSampleCountPercentile() : "");
payload.put("Trigger", trigger);
return objectMapper.writeValueAsString(payload);
}
private String timestamp(long epochSeconds) {
return TIMESTAMP_FORMAT.format(Instant.ofEpochSecond(epochSeconds));
}
}
@@ -0,0 +1,34 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.AwsException;
/**
* A CloudWatch error whose Query code and JSON shape name differ.
*
* <p>CloudWatch is awsQueryCompatible: the Query {@code <Code>} and the
* {@code x-amzn-query-error} header carry the {@code awsQueryError} code
* ({@code MissingParameter}), while a JSON 1.0 or CBOR body's {@code __type} names the
* model shape ({@code MissingRequiredParameterException}). The AWS SDK v2 picks the typed
* exception from {@code __type}, so the code alone yields a generic {@code CloudWatchException}.
* Keeping {@link #getErrorCode()} as the Query code and overriding {@link #jsonType()}
* reproduces both surfaces.
*/
class CloudWatchMetricsException extends AwsException {
private final String jsonType;
private CloudWatchMetricsException(String errorCode, String jsonType, String message, int httpStatus) {
super(errorCode, message, httpStatus);
this.jsonType = jsonType;
}
static CloudWatchMetricsException missingParameter(String parameter) {
return new CloudWatchMetricsException("MissingParameter", "MissingRequiredParameterException",
"The parameter " + parameter + " is required.", 400);
}
@Override
public String jsonType() {
return jsonType;
}
}
@@ -5,13 +5,21 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsErrorResponse;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.PaginatedResult;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
@@ -56,6 +64,7 @@ public class CloudWatchMetricsJsonHandler {
case "ListMetrics" -> handleListMetrics(request, region);
case "GetMetricStatistics" -> handleGetMetricStatistics(request, region);
case "PutMetricAlarm" -> handlePutMetricAlarm(request, region);
case "PutCompositeAlarm" -> handlePutCompositeAlarm(request, region);
case "DescribeAlarms" -> handleDescribeAlarms(request, region);
case "DeleteAlarms" -> handleDeleteAlarms(request, region);
case "SetAlarmState" -> handleSetAlarmState(request, region);
@@ -81,10 +90,14 @@ public class CloudWatchMetricsJsonHandler {
private Response handlePutMetricData(JsonNode request, String region) {
String namespace = request.path("Namespace").asText();
LOG.infov("JSON PutMetricData raw: {0}", request);
List<MetricDatum> datums = parseMetricDataJson(request.path("MetricData"));
LOG.infov("JSON PutMetricData parsed {0} datums, sums={1}", datums.size(),
datums.stream().map(d -> d.getMetricName() + "=" + d.getSum()).toList());
List<MetricDatum> datums = parseMetricDataJson(request.path("MetricData"), "MetricData");
int entityIndex = 0;
for (JsonNode entityMetricData : request.path("EntityMetricData")) {
entityIndex++;
datums.addAll(parseMetricDataJson(entityMetricData.path("MetricData"),
"EntityMetricData.member." + entityIndex + ".MetricData"));
}
LOG.debugv("JSON PutMetricData parsed {0} datums for namespace {1}", datums.size(), namespace);
metricsService.putMetricData(namespace, datums, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
@@ -151,12 +164,17 @@ public class CloudWatchMetricsJsonHandler {
private Response handlePutMetricAlarm(JsonNode request, String region) {
MetricAlarm alarm = new MetricAlarm();
alarm.setAlarmName(request.path("AlarmName").asText());
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
parseAlarmFields(request, alarm);
alarm.setMetricName(request.path("MetricName").asText(null));
alarm.setNamespace(request.path("Namespace").asText(null));
alarm.setStatistic(request.path("Statistic").asText(null));
alarm.setPeriod(request.path("Period").asInt(60));
JsonNode metrics = request.path("Metrics");
if (metrics.isArray()) {
for (JsonNode query : metrics) {
alarm.getMetrics().add(objectMapper.convertValue(query, AlarmMetricDataQuery.class));
}
}
alarm.setPeriod(alarm.getMetrics().isEmpty() ? request.path("Period").asInt(60) : null);
alarm.setUnit(request.path("Unit").asText(null));
alarm.setEvaluationPeriods(request.path("EvaluationPeriods").asInt(1));
alarm.setDatapointsToAlarm(request.hasNonNull("DatapointsToAlarm")
@@ -164,9 +182,33 @@ public class CloudWatchMetricsJsonHandler {
alarm.setThreshold(request.path("Threshold").asDouble(0));
alarm.setComparisonOperator(request.path("ComparisonOperator").asText(null));
alarm.setTreatMissingData(request.path("TreatMissingData").asText(null));
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
alarm.setDimensions(parseDimensionsJson(request.path("Dimensions")));
metricsService.putMetricAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private Response handlePutCompositeAlarm(JsonNode request, String region) {
rejectSuppressor(request);
CompositeAlarm alarm = new CompositeAlarm();
parseAlarmFields(request, alarm);
alarm.setAlarmRule(request.path("AlarmRule").asText(null));
metricsService.putCompositeAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private static void rejectSuppressor(JsonNode request) {
if (request.has("ActionsSuppressor") || request.has("ActionsSuppressorWaitPeriod")
|| request.has("ActionsSuppressorExtensionPeriod")) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
}
private void parseAlarmFields(JsonNode request, Alarm alarm) {
alarm.setAlarmName(request.path("AlarmName").asText());
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
JsonNode alarmActions = request.path("AlarmActions");
if (alarmActions.isArray()) {
alarmActions.forEach(a -> alarm.getAlarmActions().add(a.asText()));
@@ -187,8 +229,6 @@ public class CloudWatchMetricsJsonHandler {
alarm.setTags(tags);
}
metricsService.putMetricAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private Response handleDescribeAlarms(JsonNode request, String region) {
@@ -199,32 +239,42 @@ public class CloudWatchMetricsJsonHandler {
}
String prefix = request.has("AlarmNamePrefix") ? request.path("AlarmNamePrefix").asText() : null;
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
List<String> types = new ArrayList<>();
request.path("AlarmTypes").forEach(value -> types.add(value.asText()));
metricsService.validateAlarmTypes(types);
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
ObjectNode response = objectMapper.createObjectNode();
ArrayNode arr = response.putArray("MetricAlarms");
for (MetricAlarm a : alarms) {
ObjectNode node = arr.addObject();
node.put("AlarmName", a.getAlarmName());
if (a.getAlarmArn() != null) node.put("AlarmArn", a.getAlarmArn());
if (a.getAlarmDescription() != null) node.put("AlarmDescription", a.getAlarmDescription());
ArrayNode alarmActions = node.putArray("AlarmActions");
a.getAlarmActions().forEach(alarmActions::add);
ArrayNode okActions = node.putArray("OKActions");
a.getOkActions().forEach(okActions::add);
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
if (a.getMetricName() != null) node.put("MetricName", a.getMetricName());
if (a.getNamespace() != null) node.put("Namespace", a.getNamespace());
if (a.getStatistic() != null) node.put("Statistic", a.getStatistic());
ArrayNode dimensions = node.putArray("Dimensions");
a.getDimensions().forEach(d -> {
ObjectNode dimNode = dimensions.addObject();
dimNode.put("Name", d.name());
dimNode.put("Value", d.value());
});
node.put("Period", a.getPeriod());
if (a.getUnit() != null) node.put("Unit", a.getUnit());
describeAlarmFields(node, a);
if (!a.getMetrics().isEmpty()) {
node.set("Metrics", objectMapper.valueToTree(a.getMetrics()));
} else {
if (a.getMetricName() != null) {
node.put("MetricName", a.getMetricName());
}
if (a.getNamespace() != null) {
node.put("Namespace", a.getNamespace());
}
if (a.getStatistic() != null) {
node.put("Statistic", a.getStatistic());
}
ArrayNode dimensions = node.putArray("Dimensions");
a.getDimensions().forEach(d -> {
ObjectNode dimNode = dimensions.addObject();
dimNode.put("Name", d.name());
dimNode.put("Value", d.value());
});
if (a.getPeriod() != null) {
node.put("Period", a.getPeriod());
}
if (a.getUnit() != null) {
node.put("Unit", a.getUnit());
}
}
node.put("EvaluationPeriods", a.getEvaluationPeriods());
if (a.getDatapointsToAlarm() != null) {
node.put("DatapointsToAlarm", a.getDatapointsToAlarm());
@@ -232,15 +282,48 @@ public class CloudWatchMetricsJsonHandler {
node.put("Threshold", a.getThreshold());
if (a.getComparisonOperator() != null) node.put("ComparisonOperator", a.getComparisonOperator());
if (a.getTreatMissingData() != null) node.put("TreatMissingData", a.getTreatMissingData());
node.put("ActionsEnabled", a.isActionsEnabled());
if (a.getStateValue() != null) node.put("StateValue", a.getStateValue());
if (a.getStateReason() != null) node.put("StateReason", a.getStateReason());
if (a.getStateReasonData() != null) node.put("StateReasonData", a.getStateReasonData());
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
}
ArrayNode composites = response.putArray("CompositeAlarms");
if (types.contains("CompositeAlarm")) {
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
ObjectNode node = composites.addObject();
describeAlarmFields(node, alarm);
node.put("AlarmRule", alarm.getAlarmRule());
}
}
response.putArray("LogAlarms");
return Response.ok(response).build();
}
private void describeAlarmFields(ObjectNode node, Alarm a) {
node.put("AlarmName", a.getAlarmName());
if (a.getAlarmArn() != null) {
node.put("AlarmArn", a.getAlarmArn());
}
if (a.getAlarmDescription() != null) {
node.put("AlarmDescription", a.getAlarmDescription());
}
ArrayNode alarmActions = node.putArray("AlarmActions");
a.getAlarmActions().forEach(alarmActions::add);
ArrayNode okActions = node.putArray("OKActions");
a.getOkActions().forEach(okActions::add);
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
node.put("ActionsEnabled", a.isActionsEnabled());
if (a.getStateValue() != null) {
node.put("StateValue", a.getStateValue());
}
if (a.getStateReason() != null) {
node.put("StateReason", a.getStateReason());
}
if (a.getStateReasonData() != null) {
node.put("StateReasonData", a.getStateReasonData());
}
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
node.put("AlarmConfigurationUpdatedTimestamp", a.getAlarmConfigurationUpdatedTimestamp());
}
private Response handleDeleteAlarms(JsonNode request, String region) {
List<String> alarmNames = new ArrayList<>();
JsonNode namesNode = request.path("AlarmNames");
@@ -586,12 +669,18 @@ public class CloudWatchMetricsJsonHandler {
return configurations;
}
private List<MetricDatum> parseMetricDataJson(JsonNode node) {
private List<MetricDatum> parseMetricDataJson(JsonNode node, String listPath) {
List<MetricDatum> datums = new ArrayList<>();
if (!node.isArray()) return datums;
int index = 0;
for (JsonNode item : node) {
index++;
JsonNode metricName = item.path("MetricName");
if (!metricName.isTextual() || metricName.asText().isEmpty()) {
throw CloudWatchMetricsException.missingParameter(listPath + ".member." + index + ".MetricName");
}
MetricDatum datum = new MetricDatum();
datum.setMetricName(item.path("MetricName").asText());
datum.setMetricName(metricName.asText());
datum.setValue(item.path("Value").asDouble(0));
datum.setUnit(item.path("Unit").asText(null));
JsonNode ts = item.path("Timestamp");
@@ -1,5 +1,6 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.AwsNamespaces;
import io.github.hectorvent.floci.core.common.AwsQueryResponse;
import io.github.hectorvent.floci.core.common.PaginatedResult;
@@ -7,10 +8,17 @@ import io.github.hectorvent.floci.core.common.Pagination;
import io.github.hectorvent.floci.core.common.XmlBuilder;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
@@ -25,11 +33,16 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.jboss.logging.Logger;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ApplicationScoped
public class CloudWatchMetricsQueryHandler {
private static final Logger LOG = Logger.getLogger(CloudWatchMetricsQueryHandler.class);
private static final Pattern ENTITY_METRIC_DATA_MEMBER = Pattern.compile("EntityMetricData\\.member\\.(\\d{1,9})\\.");
private CloudWatchMetricsService metricsService;
private final CloudWatchDashboardsService dashboardsService;
@@ -52,6 +65,7 @@ public class CloudWatchMetricsQueryHandler {
case "GetMetricStatistics" -> handleGetMetricStatistics(params, region);
case "GetMetricData" -> handleGetMetricData(params, region);
case "PutMetricAlarm" -> handlePutMetricAlarm(params, region);
case "PutCompositeAlarm" -> handlePutCompositeAlarm(params, region);
case "DescribeAlarms" -> handleDescribeAlarms(params, region);
case "DeleteAlarms" -> handleDeleteAlarms(params, region);
case "SetAlarmState" -> handleSetAlarmState(params, region);
@@ -153,7 +167,8 @@ public class CloudWatchMetricsQueryHandler {
xml.elem("Unit", dp.unit()).end("member");
}
xml.end("Datapoints");
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics", AwsNamespaces.CW, xml.build())).build();
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics",
AwsNamespaces.CW, xml.build())).build();
}
private Response handleGetMetricData(MultivaluedMap<String, String> params, String region) {
@@ -237,6 +252,26 @@ public class CloudWatchMetricsQueryHandler {
return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build();
}
private Response handlePutCompositeAlarm(MultivaluedMap<String, String> params, String region) {
if (params.containsKey("ActionsSuppressor") || params.containsKey("ActionsSuppressorWaitPeriod")
|| params.containsKey("ActionsSuppressorExtensionPeriod")) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
CompositeAlarm alarm = new CompositeAlarm();
parseAlarmFields(params, alarm);
alarm.setAlarmRule(params.getFirst("AlarmRule"));
metricsService.putCompositeAlarm(alarm, region);
return Response.ok(AwsQueryResponse.envelopeNoResult("PutCompositeAlarm", null)).build();
}
private static List<String> alarmMembers(MultivaluedMap<String, String> params, String key) {
List<String> values = new ArrayList<>();
for (int i = 1; params.getFirst(key + ".member." + i) != null; i++) {
values.add(params.getFirst(key + ".member." + i));
}
return values;
}
private Response handleDescribeAlarms(MultivaluedMap<String, String> params, String region) {
List<String> alarmNames = new ArrayList<>();
for (int i = 1; ; i++) {
@@ -246,13 +281,24 @@ public class CloudWatchMetricsQueryHandler {
}
String prefix = params.getFirst("AlarmNamePrefix");
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
List<String> types = alarmMembers(params, "AlarmTypes");
metricsService.validateAlarmTypes(types);
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
var xml = new XmlBuilder().start("MetricAlarms");
for (MetricAlarm a : alarms) {
toAlarmXml(xml, a);
}
xml.end("MetricAlarms");
xml.end("MetricAlarms").start("CompositeAlarms");
if (types.contains("CompositeAlarm")) {
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
xml.start("member");
describeAlarmFields(xml, alarm);
xml.elem("AlarmRule", alarm.getAlarmRule()).end("member");
}
}
xml.end("CompositeAlarms").start("LogAlarms").end("LogAlarms");
return Response.ok(AwsQueryResponse.envelope("DescribeAlarms", null, xml.build())).build();
}
@@ -590,42 +636,63 @@ public class CloudWatchMetricsQueryHandler {
// ──────────────────────────── Parsing Helpers ────────────────────────────
private List<MetricDatum> parseMetricData(MultivaluedMap<String, String> params) {
LOG.infov("Query PutMetricData params: {0}", params);
List<MetricDatum> datums = parseMetricData(params, "MetricData");
// An empty EntityMetricData entry serializes to no keys, so its index is absent
// while later entries keep theirs: iterate the indexes present, not 1..first gap.
SortedSet<Integer> entityIndexes = new TreeSet<>();
for (String key : params.keySet()) {
Matcher matcher = ENTITY_METRIC_DATA_MEMBER.matcher(key);
if (matcher.lookingAt()) {
entityIndexes.add(Integer.parseInt(matcher.group(1)));
}
}
for (int index : entityIndexes) {
datums.addAll(parseMetricData(params, "EntityMetricData.member." + index + ".MetricData"));
}
LOG.debugv("Query PutMetricData parsed {0} datums", datums.size());
return datums;
}
private List<MetricDatum> parseMetricData(MultivaluedMap<String, String> params, String listPrefix) {
List<MetricDatum> datums = new ArrayList<>();
for (int i = 1; ; i++) {
String metricName = params.getFirst("MetricData.member." + i + ".MetricName");
if (metricName == null) {
break;
String memberPrefix = listPrefix + ".member." + i + ".";
String metricName = params.getFirst(memberPrefix + "MetricName");
if (metricName == null || metricName.isEmpty()) {
if (metricName == null && params.keySet().stream().noneMatch(key -> key.startsWith(memberPrefix))) {
break;
}
throw CloudWatchMetricsException.missingParameter(memberPrefix + "MetricName");
}
MetricDatum datum = new MetricDatum();
datum.setMetricName(metricName);
datum.setUnit(params.getFirst("MetricData.member." + i + ".Unit"));
datum.setUnit(params.getFirst(listPrefix + ".member." + i + ".Unit"));
String valueStr = params.getFirst("MetricData.member." + i + ".Value");
String valueStr = params.getFirst(listPrefix + ".member." + i + ".Value");
if (valueStr != null) {
datum.setValue(parseDouble(valueStr, 0));
}
String tsStr = params.getFirst("MetricData.member." + i + ".Timestamp");
String tsStr = params.getFirst(listPrefix + ".member." + i + ".Timestamp");
if (tsStr != null) {
Instant ts = parseInstant(tsStr);
datum.setTimestamp(ts != null ? ts.getEpochSecond() : 0);
}
// StatisticValues
String sc = params.getFirst("MetricData.member." + i + ".StatisticValues.SampleCount");
String sc = params.getFirst(listPrefix + ".member." + i + ".StatisticValues.SampleCount");
if (sc != null) {
datum.setSampleCount(parseDouble(sc, 0));
datum.setSum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Sum"), 0));
datum.setMinimum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Minimum"), 0));
datum.setMaximum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Maximum"), 0));
datum.setSum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Sum"), 0));
datum.setMinimum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Minimum"), 0));
datum.setMaximum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Maximum"), 0));
}
// Dimensions
List<Dimension> dims = new ArrayList<>();
for (int j = 1; ; j++) {
String dimName = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Name");
String dimValue = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Value");
String dimName = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Name");
String dimValue = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Value");
if (dimName == null) {
break;
}
@@ -653,14 +720,12 @@ public class CloudWatchMetricsQueryHandler {
private MetricAlarm parseAlarm(MultivaluedMap<String, String> params) {
MetricAlarm a = new MetricAlarm();
a.setAlarmName(params.getFirst("AlarmName"));
a.setAlarmDescription(params.getFirst("AlarmDescription"));
String actionsEnabled = params.getFirst("ActionsEnabled");
a.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
parseAlarmFields(params, a);
a.setMetricName(params.getFirst("MetricName"));
a.setNamespace(params.getFirst("Namespace"));
a.setStatistic(params.getFirst("Statistic"));
a.setPeriod(parseIntParam(params, "Period", 60));
a.setMetrics(parseAlarmMetricQueries(params));
a.setPeriod(a.getMetrics().isEmpty() ? parseIntParam(params, "Period", 60) : null);
a.setUnit(params.getFirst("Unit"));
a.setEvaluationPeriods(parseIntParam(params, "EvaluationPeriods", 1));
String datapointsToAlarm = params.getFirst("DatapointsToAlarm");
@@ -680,38 +745,100 @@ public class CloudWatchMetricsQueryHandler {
}
a.setDimensions(dims);
// Actions
for (int i = 1; ; i++) {
String act = params.getFirst("OKActions.member." + i);
if (act == null) break;
a.getOkActions().add(act);
}
for (int i = 1; ; i++) {
String act = params.getFirst("AlarmActions.member." + i);
if (act == null) break;
a.getAlarmActions().add(act);
}
for (int i = 1; ; i++) {
String act = params.getFirst("InsufficientDataActions.member." + i);
if (act == null) break;
a.getInsufficientDataActions().add(act);
}
// Tags
Map<String, String> tags = new LinkedHashMap<>();
for (int i = 1; ; i++) {
String key = params.getFirst("Tags.member." + i + ".Key");
if (key == null) break;
tags.put(key, params.getFirst("Tags.member." + i + ".Value"));
}
a.setTags(tags);
return a;
}
private void parseAlarmFields(MultivaluedMap<String, String> params, Alarm alarm) {
alarm.setAlarmName(params.getFirst("AlarmName"));
alarm.setAlarmDescription(params.getFirst("AlarmDescription"));
String actionsEnabled = params.getFirst("ActionsEnabled");
alarm.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
alarm.setAlarmActions(alarmMembers(params, "AlarmActions"));
alarm.setOkActions(alarmMembers(params, "OKActions"));
alarm.setInsufficientDataActions(alarmMembers(params, "InsufficientDataActions"));
Map<String, String> tags = new LinkedHashMap<>();
for (int i = 1; params.getFirst("Tags.member." + i + ".Key") != null; i++) {
tags.put(params.getFirst("Tags.member." + i + ".Key"), params.getFirst("Tags.member." + i + ".Value"));
}
alarm.setTags(tags);
}
private List<AlarmMetricDataQuery> parseAlarmMetricQueries(MultivaluedMap<String, String> params) {
List<AlarmMetricDataQuery> queries = new ArrayList<>();
for (int index = 1; ; index++) {
String prefix = "Metrics.member." + index;
String id = params.getFirst(prefix + ".Id");
if (id == null) {
break;
}
AlarmMetricDataQuery.MetricStat metricStat = null;
String metricPrefix = prefix + ".MetricStat.Metric";
if (params.getFirst(metricPrefix + ".Namespace") != null) {
List<AlarmMetricDataQuery.MetricDimension> dimensions = null;
for (int dimensionIndex = 1; ; dimensionIndex++) {
String dimensionPrefix = metricPrefix + ".Dimensions.member." + dimensionIndex;
String name = params.getFirst(dimensionPrefix + ".Name");
if (name == null) {
break;
}
if (dimensions == null) {
dimensions = new ArrayList<>();
}
dimensions.add(new AlarmMetricDataQuery.MetricDimension(
name, params.getFirst(dimensionPrefix + ".Value")));
}
AlarmMetricDataQuery.Metric metric = new AlarmMetricDataQuery.Metric(
params.getFirst(metricPrefix + ".Namespace"),
params.getFirst(metricPrefix + ".MetricName"), dimensions);
metricStat = new AlarmMetricDataQuery.MetricStat(metric,
optionalInteger(params.getFirst(prefix + ".MetricStat.Period")),
params.getFirst(prefix + ".MetricStat.Stat"), params.getFirst(prefix + ".MetricStat.Unit"));
}
String returnData = params.getFirst(prefix + ".ReturnData");
queries.add(new AlarmMetricDataQuery(id, params.getFirst(prefix + ".Expression"),
params.getFirst(prefix + ".Label"), returnData == null ? null : Boolean.valueOf(returnData),
optionalInteger(params.getFirst(prefix + ".Period")),
params.getFirst(prefix + ".AccountId"), metricStat));
}
return queries;
}
private static Integer optionalInteger(String value) {
return value == null ? null : Integer.valueOf(value);
}
private void toAlarmXml(XmlBuilder xml, MetricAlarm a) {
xml.start("member")
.elem("AlarmName", a.getAlarmName())
xml.start("member");
describeAlarmFields(xml, a);
if (!a.getMetrics().isEmpty()) {
toAlarmMetricsXml(xml, a.getMetrics());
} else {
xml.start("Dimensions");
for (Dimension d : a.getDimensions()) {
xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member");
}
xml.end("Dimensions")
.elem("MetricName", a.getMetricName())
.elem("Namespace", a.getNamespace())
.elem("Statistic", a.getStatistic())
.elem("Period", a.getPeriod() == null ? null : a.getPeriod().toString())
.elem("Unit", a.getUnit());
}
xml.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
.elem("Threshold", String.valueOf(a.getThreshold()))
.elem("ComparisonOperator", a.getComparisonOperator())
.elem("TreatMissingData", a.getTreatMissingData());
// Only an alarm the caller gave an M for carries the member, matching what AWS returns.
if (a.getDatapointsToAlarm() != null) {
xml.elem("DatapointsToAlarm", String.valueOf(a.getDatapointsToAlarm()));
}
xml.end("member");
}
private void describeAlarmFields(XmlBuilder xml, Alarm a) {
xml.elem("AlarmName", a.getAlarmName())
.elem("AlarmArn", a.getAlarmArn())
.elem("AlarmDescription", a.getAlarmDescription());
xml.start("AlarmActions");
@@ -726,31 +853,41 @@ public class CloudWatchMetricsQueryHandler {
xml.start("InsufficientDataActions");
a.getInsufficientDataActions().forEach(act -> xml.elem("member", act));
xml.end("InsufficientDataActions");
xml.start("Dimensions");
for (Dimension d : a.getDimensions()) {
xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member");
}
xml.end("Dimensions");
xml.elem("StateValue", a.getStateValue())
.elem("StateReason", a.getStateReason())
.elem("StateReasonData", a.getStateReasonData())
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString())
.elem("MetricName", a.getMetricName())
.elem("Namespace", a.getNamespace())
.elem("Statistic", a.getStatistic())
.elem("Period", String.valueOf(a.getPeriod()))
.elem("Unit", a.getUnit())
.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
.elem("Threshold", String.valueOf(a.getThreshold()))
.elem("ComparisonOperator", a.getComparisonOperator())
.elem("TreatMissingData", a.getTreatMissingData());
// Only an alarm the caller gave an M for carries the member, matching what AWS returns.
if (a.getDatapointsToAlarm() != null) {
xml.elem("DatapointsToAlarm", String.valueOf(a.getDatapointsToAlarm()));
}
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString());
}
xml.end("member");
private void toAlarmMetricsXml(XmlBuilder xml, List<AlarmMetricDataQuery> metrics) {
xml.start("Metrics");
for (AlarmMetricDataQuery query : metrics) {
xml.start("member").elem("Id", query.id()).elem("Expression", query.expression())
.elem("Label", query.label()).elem("AccountId", query.accountId())
.elem("Period", query.period() == null ? null : query.period().toString())
.elem("ReturnData", query.returnData() == null ? null : query.returnData().toString());
AlarmMetricDataQuery.MetricStat stat = query.metricStat();
if (stat != null) {
xml.start("MetricStat");
AlarmMetricDataQuery.Metric metric = stat.metric();
if (metric != null) {
xml.start("Metric").elem("Namespace", metric.namespace()).elem("MetricName", metric.metricName());
if (metric.dimensions() != null) {
xml.start("Dimensions");
for (AlarmMetricDataQuery.MetricDimension dimension : metric.dimensions()) {
xml.start("member").elem("Name", dimension.name()).elem("Value", dimension.value())
.end("member");
}
xml.end("Dimensions");
}
xml.end("Metric");
}
xml.elem("Period", stat.period() == null ? null : stat.period().toString())
.elem("Stat", stat.stat()).elem("Unit", stat.unit()).end("MetricStat");
}
xml.end("member");
}
xml.end("Metrics");
}
private Instant parseInstant(String value) {
@@ -1,10 +1,15 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.core.type.TypeReference;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
@@ -14,11 +19,16 @@ import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -27,25 +37,41 @@ public class CloudWatchMetricsService {
private static final Logger LOG = Logger.getLogger(CloudWatchMetricsService.class);
private static final List<String> ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm");
private final StorageBackend<String, MetricDatum> metricStore;
private final StorageBackend<String, MetricAlarm> alarmStore;
private final StorageBackend<String, Alarm> alarmStore;
private final RegionResolver regionResolver;
private final AlarmSnsNotifier alarmSnsNotifier;
public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver) {
this(storageFactory, regionResolver, null);
}
@Inject
public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver) {
public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver,
AlarmSnsNotifier alarmSnsNotifier) {
this.metricStore = storageFactory.create("cloudwatchmetrics", "cwmetrics.json",
new TypeReference<Map<String, MetricDatum>>() {});
this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json",
new TypeReference<Map<String, MetricAlarm>>() {});
new TypeReference<Map<String, Alarm>>() {});
this.regionResolver = regionResolver;
this.alarmSnsNotifier = alarmSnsNotifier;
}
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
StorageBackend<String, MetricAlarm> alarmStore,
StorageBackend<String, Alarm> alarmStore,
RegionResolver regionResolver) {
this(metricStore, alarmStore, regionResolver, null);
}
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
StorageBackend<String, Alarm> alarmStore,
RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) {
this.metricStore = metricStore;
this.alarmStore = alarmStore;
this.regionResolver = regionResolver;
this.alarmSnsNotifier = alarmSnsNotifier;
}
public void putMetricData(String namespace, List<MetricDatum> datums, String region) {
@@ -282,85 +308,293 @@ public class CloudWatchMetricsService {
};
}
public void putMetricAlarm(MetricAlarm alarm, String region) {
public synchronized void putMetricAlarm(MetricAlarm alarm, String region) {
putAlarm(alarm, region);
evaluateComposites(region, Set.of(alarm.getAlarmName()), null);
}
public void putCompositeAlarm(CompositeAlarm alarm, String region) {
putCompositeAlarm(alarm, region, true);
}
public synchronized void putCompositeAlarm(CompositeAlarm alarm, String region, boolean replaceExisting) {
if (!replaceExisting && alarmStore.get(region + "::" + alarm.getAlarmName()).isPresent()) {
throw new AwsException("AlreadyExists", "Alarm already exists: " + alarm.getAlarmName(), 400);
}
Set<String> references = alarmReferences(alarm, region);
if (references.size() > 100) {
throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400);
}
List<CompositeAlarm> existing = describeCompositeAlarms(null, null, region);
for (String reference : references) {
long parents = existing.stream().filter(parent -> !parent.getAlarmName().equals(alarm.getAlarmName()))
.filter(parent -> alarmReferences(parent, region).contains(reference))
.count();
if (parents >= 150) {
throw new AwsException("LimitExceeded", "An alarm can be referenced by at most 150 composite alarms", 400);
}
}
boolean created = alarmStore.get(region + "::" + alarm.getAlarmName()).isEmpty();
putAlarm(alarm, region);
if (created) {
evaluateComposites(region, Set.of(), alarm.getAlarmName());
}
}
private Set<String> alarmReferences(CompositeAlarm alarm, String region) {
return CompositeAlarmRule.parse(alarm.getAlarmRule()).references().stream()
.map(reference -> AwsArnUtils.isArn(reference) ? reference
: regionResolver.buildArn("cloudwatch", region, "alarm:" + reference))
.collect(Collectors.toSet());
}
private void putAlarm(Alarm alarm, String region) {
String name = alarm.getAlarmName();
if (name == null || name.isBlank() || name.length() > 255) {
throw SmithyValidationErrors.validationError(name == null ? SmithyValidationErrors.notNull("alarmName")
: "Value '" + name + "' at 'alarmName' failed to satisfy constraint: Member must have length "
+ (name.length() > 255 ? "less than or equal to 255" : "greater than or equal to 1"));
}
Alarm previous = alarmStore.get(region + "::" + name).orElse(null);
if (previous != null) {
if (!previous.getClass().equals(alarm.getClass())) {
throw new AwsException("ValidationError", "An alarm of another type already has this name", 400);
}
if (alarm instanceof CompositeAlarm) {
alarm.setStateValue(previous.getStateValue());
alarm.setStateReason(previous.getStateReason());
alarm.setStateReasonData(previous.getStateReasonData());
alarm.setStateUpdatedTimestamp(previous.getStateUpdatedTimestamp());
alarm.setTags(new HashMap<>(previous.getTags()));
}
}
if (alarm.getAlarmArn() == null) {
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + alarm.getAlarmName()));
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
}
alarm.setRegion(region);
alarm.setAlarmConfigurationUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
LOG.infov("PutMetricAlarm: {0} in {1}", alarm.getAlarmName(), region);
alarmStore.put(region + "::" + name, alarm);
LOG.infov("Put {0}: {1} in {2}", alarm.getClass().getSimpleName(), name, region);
}
/** Every stored alarm, across all regions. Used by the background {@link AlarmEvaluator}
* tick, which has no per-request region to scope a lookup to. */
/** Metric alarms evaluated by the existing metric evaluator. */
public List<MetricAlarm> allAlarms() {
return alarmStore.scan(k -> true);
return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance)
.map(MetricAlarm.class::cast).toList();
}
public List<MetricAlarm> describeAlarms(List<String> alarmNames, String alarmNamePrefix, String region) {
String prefix = region + "::";
List<MetricAlarm> all = alarmStore.scan(k -> k.startsWith(prefix));
if (alarmNames != null && !alarmNames.isEmpty()) {
return all.stream().filter(a -> alarmNames.contains(a.getAlarmName())).toList();
}
if (alarmNamePrefix != null && !alarmNamePrefix.isBlank()) {
return all.stream().filter(a -> a.getAlarmName().startsWith(alarmNamePrefix)).toList();
}
return all;
public List<MetricAlarm> describeAlarms(List<String> names, String prefix, String region) {
return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance)
.map(MetricAlarm.class::cast).toList();
}
public void deleteAlarms(List<String> alarmNames, String region) {
public void validateAlarmTypes(List<String> types) {
if (!ALARM_TYPES.containsAll(types)) {
throw SmithyValidationErrors.validationError(
SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES));
}
}
public List<CompositeAlarm> describeCompositeAlarms(List<String> names, String prefix, String region) {
return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance)
.map(CompositeAlarm.class::cast).toList();
}
private List<Alarm> matchingAlarms(List<String> names, String prefix, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::")).stream()
.filter(a -> names == null || names.isEmpty() || names.contains(a.getAlarmName()))
.filter(a -> prefix == null || a.getAlarmName().startsWith(prefix)).toList();
}
private Map<String, Alarm> alarmIndex(String region) {
Map<String, Alarm> alarms = new HashMap<>();
for (Alarm alarm : alarmStore.scan(k -> k.startsWith(region + "::"))) {
alarms.put(alarm.getAlarmName(), alarm);
alarms.put(alarm.getAlarmArn(), alarm);
}
return alarms;
}
private void evaluateComposites(String region, Set<String> changedAlarmNames, String newAlarm) {
if (changedAlarmNames.isEmpty() && newAlarm == null) {
return;
}
Map<String, Alarm> alarms = alarmIndex(region);
Set<String> changed = new HashSet<>(changedAlarmNames);
for (String name : changedAlarmNames) {
changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
}
Map<String, CompositeAlarmRule.Rule> candidates = new HashMap<>();
for (Alarm alarm : new HashSet<>(alarms.values())) {
if (alarm instanceof CompositeAlarm composite && !changedAlarmNames.contains(alarm.getAlarmName())) {
candidates.put(alarm.getAlarmName(), CompositeAlarmRule.parse(composite.getAlarmRule()));
}
}
Set<String> affected = new HashSet<>(changed);
Map<String, CompositeAlarmRule.Rule> pending = new HashMap<>();
boolean discovered;
do {
discovered = false;
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = candidates.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
if (next.getKey().equals(newAlarm) || next.getValue().references().stream().anyMatch(affected::contains)) {
pending.put(next.getKey(), next.getValue());
affected.add(next.getKey());
affected.add(alarms.get(next.getKey()).getAlarmArn());
iterator.remove();
discovered = true;
}
}
} while (discovered);
Set<String> cycles = pending.keySet().stream()
.filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet());
boolean progress;
do {
progress = false;
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = pending.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
boolean blocked = next.getValue().references().stream().map(alarms::get)
.anyMatch(child -> child != null && pending.containsKey(child.getAlarmName()));
if (blocked || cycles.contains(next.getKey())) {
continue;
}
if (!next.getKey().equals(newAlarm)
&& next.getValue().references().stream().noneMatch(changed::contains)) {
iterator.remove();
progress = true;
continue;
}
Alarm alarm = alarms.get(next.getKey());
boolean triggered = next.getValue().evaluate(reference -> {
Alarm child = alarms.get(reference);
return child == null ? null : child.getStateValue();
});
String state = triggered ? "ALARM" : "OK";
if (!state.equals(alarm.getStateValue())) {
alarm.setStateValue(state);
alarm.setStateReason("AlarmRule evaluated to " + state);
alarm.setStateReasonData(null);
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
changed.add(alarm.getAlarmName());
changed.add(alarm.getAlarmArn());
}
iterator.remove();
progress = true;
}
} while (progress && !pending.isEmpty());
if (!pending.isEmpty()) {
LOG.debugv("Composite alarm evaluation stopped at cyclic dependencies: {0}", pending.keySet());
}
}
public synchronized void deleteAlarms(List<String> alarmNames, String region) {
Map<String, Alarm> alarms = alarmIndex(region);
long composites = alarmNames.stream().map(alarms::get).filter(CompositeAlarm.class::isInstance).count();
if (composites > 1) {
throw new AwsException("ValidationError", "DeleteAlarms accepts at most one composite alarm", 400);
}
for (String name : alarmNames) {
if (isCyclic(name, alarms)) {
throw new AwsException("ValidationError", "Break the composite alarm cycle before deleting " + name, 400);
}
}
Set<String> deletedNames = new HashSet<>();
for (String name : alarmNames) {
if (alarmStore.get(region + "::" + name).isPresent()) {
deletedNames.add(name);
}
alarmStore.delete(region + "::" + name);
}
evaluateComposites(region, deletedNames, null);
LOG.infov("Deleted alarms: {0} in {1}", alarmNames, region);
}
public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) {
String key = region + "::" + alarmName;
MetricAlarm alarm = alarmStore.get(key)
.orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404));
alarm.setStateValue(stateValue);
alarm.setStateReason(stateReason);
alarm.setStateReasonData(stateReasonData);
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(key, alarm);
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
private boolean isCyclic(String name, Map<String, Alarm> alarms) {
Set<String> visited = new HashSet<>();
ArrayDeque<String> pending = new ArrayDeque<>();
pending.add(name);
while (!pending.isEmpty()) {
Alarm next = alarms.get(pending.remove());
if (next instanceof CompositeAlarm composite && visited.add(next.getAlarmName())) {
for (String reference : CompositeAlarmRule.parse(composite.getAlarmRule()).references()) {
Alarm child = alarms.get(reference);
if (child != null && child.getAlarmName().equals(name)) {
return true;
}
if (child != null) {
pending.add(child.getAlarmName());
}
}
}
}
return false;
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) {
List<AlarmSnsNotifier.Notification> notifications = List.of();
synchronized (this) {
String key = region + "::" + alarmName;
Alarm alarm = alarmStore.get(key)
.orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404));
String oldState = alarm.getStateValue();
alarm.setStateValue(stateValue);
alarm.setStateReason(stateReason);
alarm.setStateReasonData(stateReasonData);
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(key, alarm);
evaluateComposites(region, Set.of(alarmName), null);
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
if (alarmSnsNotifier != null && alarm instanceof MetricAlarm metricAlarm) {
notifications = alarmSnsNotifier.prepareTransition(metricAlarm, oldState);
}
}
if (alarmSnsNotifier != null) {
alarmSnsNotifier.publish(notifications);
}
}
/**
* Resolves the alarm an ARN names, or reports that nothing does.
*
* <p>CloudWatch's three tag operations each declare {@code ResourceNotFoundException},
* which is a different shape from the {@code ResourceNotFound} that {@code SetAlarmState}
* and {@code GetDashboard} declare; the SDK maps the two codes to two exception classes,
* so the tag path uses the longer one rather than the code used elsewhere in this service.
*
* <p>The message names the ARN rather than asserting it was an alarm, because both tag
* handlers route every ARN that is not a dashboard and not a metric stream here. That
* includes kinds AWS considers taggable and Floci does not serve, a Contributor Insights
* {@code insight-rule/} ARN being the one AWS documents; reporting that no resource
* matches the ARN is true of those, where "alarm not found" would not be.
*/
private Alarm requireAlarm(String resourceArn, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::"))
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn))
.findFirst()
.map(MetricAlarm::getTags)
.orElse(Map.of());
.orElseThrow(() -> new AwsException("ResourceNotFoundException",
"No CloudWatch resource matches the ARN " + resourceArn + ".", 404));
}
public void tagResource(String resourceArn, Map<String, String> tags, String region) {
alarmStore.scan(k -> k.startsWith(region + "::"))
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
alarm.getTags().putAll(tags);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
public synchronized Map<String, String> listTagsForResource(String resourceArn, String region) {
return new HashMap<>(requireAlarm(resourceArn, region).getTags());
}
public void untagResource(String resourceArn, List<String> tagKeys, String region) {
alarmStore.scan(k -> k.startsWith(region + "::"))
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
tagKeys.forEach(alarm.getTags()::remove);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
public synchronized void tagResource(String resourceArn, Map<String, String> tags, String region) {
Alarm alarm = requireAlarm(resourceArn, region);
alarm.getTags().putAll(tags);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
}
public synchronized void untagResource(String resourceArn, List<String> tagKeys, String region) {
Alarm alarm = requireAlarm(resourceArn, region);
tagKeys.forEach(alarm.getTags()::remove);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
}
// ──────────────────────────── Helpers ────────────────────────────
@@ -0,0 +1,184 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
/** CloudWatch's boolean alarm language, parsed completely before any alarm is stored. */
final class CompositeAlarmRule {
private final String input;
private final Set<String> references = new HashSet<>();
private int offset;
private int elements;
private CompositeAlarmRule(String input) {
this.input = input;
}
static Rule parse(String input) {
if (input == null || input.isBlank() || input.length() > 10240) {
throw SmithyValidationErrors.validationError(input == null ? SmithyValidationErrors.notNull("alarmRule")
: "Value '" + input + "' at 'alarmRule' failed to satisfy constraint: Member must have length "
+ (input.length() > 10240 ? "less than or equal to 10240" : "greater than or equal to 1"));
}
CompositeAlarmRule parser = new CompositeAlarmRule(input);
Expression expression = parser.or();
parser.whitespace();
if (parser.offset != input.length()) {
throw invalid("Invalid AlarmRule");
}
return new Rule(expression, Set.copyOf(parser.references));
}
private Expression or() {
Expression left = and();
while (word("OR")) {
Expression prior = left;
Expression right = and();
left = states -> prior.evaluate(states) || right.evaluate(states);
}
return left;
}
private Expression and() {
Expression left = atom();
while (word("AND")) {
Expression prior = left;
Expression right = atom();
left = states -> prior.evaluate(states) && right.evaluate(states);
}
return left;
}
private Expression atom() {
boolean negate = false;
while (word("NOT")) {
negate = !negate;
}
Expression operand = primary();
return negate ? states -> !operand.evaluate(states) : operand;
}
private Expression primary() {
element();
if (character('(')) {
Expression nested = or();
require(')');
element();
return nested;
}
if (word("TRUE")) {
return states -> true;
}
if (word("FALSE")) {
return states -> false;
}
String state;
if (word("ALARM")) {
state = "ALARM";
} else if (word("OK")) {
state = "OK";
} else if (word("INSUFFICIENT_DATA")) {
state = "INSUFFICIENT_DATA";
} else {
throw invalid("Expected an alarm state, boolean, NOT, or parenthesis");
}
require('(');
whitespace();
String name;
if (character('"')) {
StringBuilder quoted = new StringBuilder();
boolean closed = false;
while (offset < input.length()) {
char next = input.charAt(offset++);
if (next == '"') {
closed = true;
break;
}
if (next == '\\') {
if (offset == input.length()) {
throw invalid("Unterminated quoted alarm name");
}
next = input.charAt(offset++);
if (next != '\\' && next != '"') {
throw invalid("Invalid quoted alarm name escape");
}
}
quoted.append(next);
}
if (!closed) {
throw invalid("Unterminated quoted alarm name");
}
name = quoted.toString();
} else {
int start = offset;
while (offset < input.length() && input.charAt(offset) != ')') {
offset++;
}
name = input.substring(start, offset).trim();
}
if (name.isEmpty()) {
throw invalid("An alarm name or ARN is required");
}
require(')');
references.add(name);
return states -> state.equals(states.apply(name));
}
private void element() {
if (++elements > 500) {
throw invalid("AlarmRule exceeds 500 child alarms, booleans, and parentheses");
}
}
private boolean word(String word) {
whitespace();
if (!input.startsWith(word, offset)) {
return false;
}
int end = offset + word.length();
if (end < input.length() && (Character.isLetterOrDigit(input.charAt(end)) || input.charAt(end) == '_')) {
return false;
}
offset = end;
return true;
}
private boolean character(char expected) {
whitespace();
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void require(char expected) {
if (!character(expected)) {
throw invalid("Expected '" + expected + "' in AlarmRule");
}
}
private void whitespace() {
while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) {
offset++;
}
}
private static AwsException invalid(String message) {
return new AwsException("ValidationError", message, 400);
}
record Rule(Expression expression, Set<String> references) {
boolean evaluate(Function<String, String> states) {
return expression.evaluate(states);
}
}
interface Expression {
boolean evaluate(Function<String, String> states);
}
}
@@ -0,0 +1,87 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "alarmType", defaultImpl = MetricAlarm.class)
@JsonSubTypes({@JsonSubTypes.Type(value = MetricAlarm.class, name = "MetricAlarm"),
@JsonSubTypes.Type(value = CompositeAlarm.class, name = "CompositeAlarm")})
public abstract class Alarm {
private String alarmName;
private String alarmArn;
private String alarmDescription;
private long alarmConfigurationUpdatedTimestamp;
private boolean actionsEnabled;
private List<String> okActions = new ArrayList<>();
private List<String> alarmActions = new ArrayList<>();
private List<String> insufficientDataActions = new ArrayList<>();
private String stateValue = "INSUFFICIENT_DATA";
private String stateReason = "Unchecked";
private String stateReasonData;
private long stateUpdatedTimestamp;
private Map<String, String> tags = new HashMap<>();
private String region;
protected Alarm() {
long now = Instant.now().getEpochSecond();
this.alarmConfigurationUpdatedTimestamp = now;
this.stateUpdatedTimestamp = now;
}
public abstract String getAlarmType();
public String getAlarmName() { return alarmName; }
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
public String getAlarmArn() { return alarmArn; }
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
public String getAlarmDescription() { return alarmDescription; }
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
public boolean isActionsEnabled() { return actionsEnabled; }
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
public List<String> getOkActions() { return okActions; }
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
public List<String> getAlarmActions() { return alarmActions; }
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
public String getStateValue() { return stateValue; }
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
public String getStateReason() { return stateReason; }
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
public String getStateReasonData() { return stateReasonData; }
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
public Map<String, String> getTags() { return tags; }
public void setTags(Map<String, String> tags) { this.tags = tags; }
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
}
@@ -0,0 +1,46 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.util.List;
/** The persisted AWS wire shape of a metric-math alarm query, including optional members. */
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AlarmMetricDataQuery(
@JsonProperty("Id") String id,
@JsonProperty("Expression") String expression,
@JsonProperty("Label") String label,
@JsonProperty("ReturnData") Boolean returnData,
@JsonProperty("Period") Integer period,
@JsonProperty("AccountId") String accountId,
@JsonProperty("MetricStat") MetricStat metricStat) {
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MetricStat(
@JsonProperty("Metric") Metric metric,
@JsonProperty("Period") Integer period,
@JsonProperty("Stat") String stat,
@JsonProperty("Unit") String unit) { }
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Metric(
@JsonProperty("Namespace") String namespace,
@JsonProperty("MetricName") String metricName,
@JsonProperty("Dimensions") List<MetricDimension> dimensions) { }
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MetricDimension(
@JsonProperty("Name") String name,
@JsonProperty("Value") String value) { }
}
@@ -0,0 +1,14 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public class CompositeAlarm extends Alarm {
private String alarmRule;
@Override
public String getAlarmType() { return "CompositeAlarm"; }
public String getAlarmRule() { return alarmRule; }
public void setAlarmRule(String alarmRule) { this.alarmRule = alarmRule; }
}
@@ -3,32 +3,22 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
public class MetricAlarm {
private String alarmName;
private String alarmArn;
private String alarmDescription;
private long alarmConfigurationUpdatedTimestamp;
private boolean actionsEnabled;
private List<String> okActions = new ArrayList<>();
private List<String> alarmActions = new ArrayList<>();
private List<String> insufficientDataActions = new ArrayList<>();
private String stateValue = "INSUFFICIENT_DATA";
private String stateReason = "Unchecked";
private String stateReasonData;
private long stateUpdatedTimestamp;
public class MetricAlarm extends Alarm {
@Override
public String getAlarmType() { return "MetricAlarm"; }
private String metricName;
private String namespace;
private String statistic;
private List<Dimension> dimensions = new ArrayList<>();
private int period;
/** Single-metric period; absent for alarms whose periods are defined in Metrics. */
private Integer period;
private List<AlarmMetricDataQuery> metrics = new ArrayList<>();
private String unit;
private int evaluationPeriods;
/**
@@ -43,50 +33,6 @@ public class MetricAlarm {
private String comparisonOperator;
private String treatMissingData = "missing";
private String evaluateLowSampleCountPercentile;
private Map<String, String> tags = new HashMap<>();
private String region;
public MetricAlarm() {
long now = Instant.now().getEpochSecond();
this.alarmConfigurationUpdatedTimestamp = now;
this.stateUpdatedTimestamp = now;
}
public String getAlarmName() { return alarmName; }
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
public String getAlarmArn() { return alarmArn; }
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
public String getAlarmDescription() { return alarmDescription; }
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
public boolean isActionsEnabled() { return actionsEnabled; }
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
public List<String> getOkActions() { return okActions; }
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
public List<String> getAlarmActions() { return alarmActions; }
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
public String getStateValue() { return stateValue; }
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
public String getStateReason() { return stateReason; }
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
public String getStateReasonData() { return stateReasonData; }
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
public String getMetricName() { return metricName; }
public void setMetricName(String metricName) { this.metricName = metricName; }
@@ -100,8 +46,13 @@ public class MetricAlarm {
public List<Dimension> getDimensions() { return dimensions; }
public void setDimensions(List<Dimension> dimensions) { this.dimensions = dimensions; }
public int getPeriod() { return period; }
public void setPeriod(int period) { this.period = period; }
public Integer getPeriod() { return period; }
public void setPeriod(Integer period) { this.period = period; }
public List<AlarmMetricDataQuery> getMetrics() { return metrics; }
public void setMetrics(List<AlarmMetricDataQuery> metrics) {
this.metrics = metrics == null ? new ArrayList<>() : new ArrayList<>(metrics);
}
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
@@ -124,11 +75,4 @@ public class MetricAlarm {
public String getEvaluateLowSampleCountPercentile() { return evaluateLowSampleCountPercentile; }
public void setEvaluateLowSampleCountPercentile(String percentile) { this.evaluateLowSampleCountPercentile = percentile; }
public Map<String, String> getTags() { return tags; }
public void setTags(Map<String, String> tags) { this.tags = tags; }
/** Not part of the AWS shape; set by {@code putMetricAlarm} so a background evaluator
* that only holds the alarm object still knows which region's metric data to query. */
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
}
@@ -196,21 +196,30 @@ public class CloudWatchMetricStreamsService {
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
return findByArn(resourceArn, region).map(MetricStream::getTags).orElse(Map.of());
return requireStream(resourceArn, region).getTags();
}
public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(stream -> {
stream.getTags().putAll(tags);
streamStore.put(key(region, stream.getName()), stream);
});
MetricStream stream = requireStream(resourceArn, region);
stream.getTags().putAll(tags);
streamStore.put(key(region, stream.getName()), stream);
}
public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(stream -> {
tagKeys.forEach(stream.getTags()::remove);
streamStore.put(key(region, stream.getName()), stream);
});
MetricStream stream = requireStream(resourceArn, region);
tagKeys.forEach(stream.getTags()::remove);
streamStore.put(key(region, stream.getName()), stream);
}
/**
* Resolves the stream an ARN names, or reports that nothing does. The tag operations declare
* {@code ResourceNotFoundException}, the same code {@code GetMetricStream} already answers
* with, so an unknown ARN here reads the same way an unknown name does.
*/
private MetricStream requireStream(String resourceArn, String region) {
return findByArn(resourceArn, region).orElseThrow(() ->
new AwsException("ResourceNotFoundException",
"Metric stream " + resourceArn + " does not exist.", 404));
}
private Optional<MetricStream> findByArn(String resourceArn, String region) {
@@ -15,6 +15,7 @@ import io.github.hectorvent.floci.core.common.docker.ContainerLifecycleManager;
import io.github.hectorvent.floci.core.common.docker.ContainerLogStreamer;
import io.github.hectorvent.floci.core.common.docker.ContainerSpec;
import io.github.hectorvent.floci.core.common.docker.ContainerStorageHelper;
import io.github.hectorvent.floci.core.common.docker.DockerClientProducer;
import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedArtifacts;
import io.github.hectorvent.floci.services.codebuild.BuildspecParser.ParsedBuildspec;
import io.github.hectorvent.floci.services.codebuild.model.Build;
@@ -38,6 +39,7 @@ import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -195,9 +197,10 @@ public class CodeBuildRunner implements ContainerTeardown {
String logGroup = "/aws/codebuild/" + project.getName();
String logStream = logStreamer.generateLogStreamName(buildId.replace(":", "/"));
String image = build.getEnvironment() != null && build.getEnvironment().getImage() != null
String requestedImage = build.getEnvironment() != null && build.getEnvironment().getImage() != null
? build.getEnvironment().getImage()
: project.getEnvironment().getImage();
String image = resolveBuildImage(requestedImage);
boolean privileged = (project.getEnvironment() != null
&& Boolean.TRUE.equals(project.getEnvironment().getPrivilegedMode()))
@@ -214,8 +217,11 @@ public class CodeBuildRunner implements ContainerTeardown {
// Keep the container alive so each phase can be run with docker exec.
// No bind mount needed — source and artifacts are transferred with docker cp.
ContainerSpec spec = containerBuilder.newContainer(image)
.withCmd(List.of("sh", "-c", "tail -f /dev/null"))
String startupCommand = "aws/codebuild/standard:7.0".equals(requestedImage)
? "apt-get update && apt-get install -y --no-install-recommends zip && tail -f /dev/null"
: "tail -f /dev/null";
ContainerBuilder.Builder container = containerBuilder.newContainer(image)
.withCmd(List.of("sh", "-c", startupCommand))
.withEnv(envList)
.withDockerNetwork(config.services().codebuild().dockerNetwork())
.withEmbeddedDns()
@@ -223,8 +229,9 @@ public class CodeBuildRunner implements ContainerTeardown {
.withPrivileged(privileged)
.withLogRotation()
.withLabels(ContainerStorageHelper.resourceIdentityLabels(
"codebuild", buildId, regionResolver.getAccountId(), region))
.build();
"codebuild", buildId, regionResolver.getAccountId(), region));
exposeDockerSocketIfPrivileged(container, privileged, config.docker());
ContainerSpec spec = container.build();
ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec);
containerId = info.containerId();
@@ -261,7 +268,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (installResult.stopped()) { finishStopped(build); return; }
if (installResult.failed()) {
completePhaseWithError(build, "INSTALL", "FAILED", installResult.errorMessage());
buildFailed = true;
finishFailed(build);
return;
} else {
completePhase(build, "INSTALL", "SUCCEEDED");
}
@@ -276,7 +284,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (preBuildResult.stopped()) { finishStopped(build); return; }
if (preBuildResult.failed()) {
completePhaseWithError(build, "PRE_BUILD", "FAILED", preBuildResult.errorMessage());
buildFailed = true;
finishFailed(build);
return;
} else {
completePhase(build, "PRE_BUILD", "SUCCEEDED");
}
@@ -294,7 +303,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (buildResult.stopped()) { finishStopped(build); return; }
if (buildResult.failed()) {
completePhaseWithError(build, "BUILD", "FAILED", buildResult.errorMessage());
buildFailed = true;
finishFailed(build);
return;
} else {
completePhase(build, "BUILD", "SUCCEEDED");
}
@@ -311,7 +321,8 @@ public class CodeBuildRunner implements ContainerTeardown {
if (postBuildResult.stopped()) { finishStopped(build); return; }
if (postBuildResult.failed()) {
completePhaseWithError(build, "POST_BUILD", "FAILED", postBuildResult.errorMessage());
buildFailed = true;
finishFailed(build);
return;
} else {
completePhase(build, "POST_BUILD", "SUCCEEDED");
}
@@ -384,6 +395,27 @@ public class CodeBuildRunner implements ContainerTeardown {
}
}
private String resolveBuildImage(String image) {
if ("aws/codebuild/standard:7.0".equals(image)) {
return "golang:1.23-bookworm";
}
return image;
}
static void exposeDockerSocketIfPrivileged(ContainerBuilder.Builder container, boolean privileged,
EmulatorConfig.DockerConfig dockerConfig) {
if (!privileged) {
return;
}
String dockerHost = DockerClientProducer.resolveDockerConnection(dockerConfig, System::getenv).host();
URI dockerUri = URI.create(dockerHost);
if (!"unix".equals(dockerUri.getScheme()) || dockerUri.getPath() == null || dockerUri.getPath().isBlank()) {
LOG.debugv("CodeBuild privileged mode cannot mount non-Unix Docker endpoint {0}", dockerHost);
return;
}
container.withBind(dockerUri.getPath(), "/var/run/docker.sock");
}
private String resolveAndAcquireSource(String region, Build build, Project project,
String buildspecOverride, Path workspace) throws IOException {
String sourceType = project.getSource() != null ? project.getSource().getType() : "NO_SOURCE";
@@ -660,25 +692,20 @@ public class CodeBuildRunner implements ContainerTeardown {
private void uploadArtifacts(String region, Build build, Project project,
ParsedArtifacts artifacts, Path workspace) throws IOException {
String type = artifacts.type();
if ("NO_ARTIFACTS".equals(type) && project.getArtifacts() != null) {
type = project.getArtifacts().getType();
if ("NO_ARTIFACTS".equals(type) && build.getArtifacts() != null) {
type = build.getArtifacts().getType();
}
if (type == null || "NO_ARTIFACTS".equals(type) || "CODEPIPELINE".equals(type)) {
if (type == null || "NO_ARTIFACTS".equals(type)) {
return;
}
if (!"S3".equals(type)) {
if (!"S3".equals(type) && !"CODEPIPELINE".equals(type)) {
return;
}
String packaging = artifacts.packaging();
if ("ZIP".equals(packaging) && project.getArtifacts() != null
&& project.getArtifacts().getPackaging() != null) {
packaging = project.getArtifacts().getPackaging();
}
String location = project.getArtifacts() != null ? project.getArtifacts().getLocation() : null;
if (location == null || location.isBlank()) {
return;
if ("ZIP".equals(packaging) && build.getArtifacts() != null
&& build.getArtifacts().getPackaging() != null) {
packaging = build.getArtifacts().getPackaging();
}
List<String> filePatterns = artifacts.files();
@@ -698,6 +725,16 @@ public class CodeBuildRunner implements ContainerTeardown {
return;
}
if ("CODEPIPELINE".equals(type)) {
build.setOutputArtifact(zipFiles(baseDir, matchedFiles, artifacts.discardPaths()));
return;
}
String location = build.getArtifacts() != null ? build.getArtifacts().getLocation() : null;
if (location == null || location.isBlank()) {
return;
}
int slash = location.indexOf('/');
String bucket = slash > 0 ? location.substring(0, slash) : location;
String prefix = slash > 0 ? location.substring(slash + 1) : "";
@@ -841,6 +878,13 @@ public class CodeBuildRunner implements ContainerTeardown {
build.setBuildComplete(true);
build.setBuildStatus("FAILED");
build.setCurrentPhase("COMPLETED");
BuildPhase completed = new BuildPhase();
completed.setPhaseType("COMPLETED");
completed.setPhaseStatus("FAILED");
completed.setStartTime(System.currentTimeMillis() / 1000.0);
completed.setEndTime(System.currentTimeMillis() / 1000.0);
completed.setDurationInSeconds(0L);
build.getPhases().add(completed);
}
private Optional<BuildPhase> findPhase(Build build, String phaseType) {
@@ -489,6 +489,14 @@ public class CodeBuildService {
return build;
}
/**
* Returns the CODEPIPELINE output produced by one completed build. This is runtime state,
* like the build itself, and must not be inferred from a project-wide artifact location.
*/
public byte[] getBuildOutputArtifact(String region, String account, String buildId) {
return getBuild(region, account, buildId).getOutputArtifact();
}
public List<Build> batchGetBuilds(String region, String account, List<String> buildIds) {
Map<String, Build> store = buildsFor(account, region);
return buildIds.stream()
@@ -1,5 +1,6 @@
package io.github.hectorvent.floci.services.codebuild.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -29,6 +30,7 @@ public class Build {
private Integer timeoutInMinutes;
private Integer queuedTimeoutInMinutes;
private String encryptionKey;
private byte[] outputArtifact;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
@@ -83,4 +85,11 @@ public class Build {
public String getEncryptionKey() { return encryptionKey; }
public void setEncryptionKey(String encryptionKey) { this.encryptionKey = encryptionKey; }
@JsonIgnore
public byte[] getOutputArtifact() { return outputArtifact != null ? outputArtifact.clone() : null; }
public void setOutputArtifact(byte[] outputArtifact) {
this.outputArtifact = outputArtifact != null ? outputArtifact.clone() : null;
}
}
@@ -17,10 +17,19 @@ import io.github.hectorvent.floci.services.codedeploy.model.OnPremisesInstance;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ecs.EcsService;
import io.github.hectorvent.floci.services.ecs.model.CreateTaskSetRequest;
import io.github.hectorvent.floci.services.ecs.model.EcsLoadBalancer;
import io.github.hectorvent.floci.services.ecs.model.EcsServiceModel;
import io.github.hectorvent.floci.services.ecs.model.LaunchType;
import io.github.hectorvent.floci.services.ecs.model.TaskSet;
import io.github.hectorvent.floci.services.elbv2.ElbV2Service;
import io.github.hectorvent.floci.services.elbv2.model.Action;
import io.github.hectorvent.floci.services.elbv2.model.Rule;
import io.github.hectorvent.floci.services.elbv2.model.TargetGroup;
import io.github.hectorvent.floci.services.elbv2.model.TargetHealth;
import io.github.hectorvent.floci.services.lambda.LambdaService;
import io.github.hectorvent.floci.services.lambda.model.LambdaAlias;
import io.github.hectorvent.floci.services.lambda.model.LambdaFunction;
import io.github.hectorvent.floci.services.s3.S3Service;
import io.github.hectorvent.floci.services.ssm.SsmCommandService;
import io.github.hectorvent.floci.services.lambda.model.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
@@ -29,6 +38,8 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -36,6 +47,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -53,8 +66,16 @@ public class CodeDeployService {
// Lambda/ECS validation hooks must call PutLifecycleEventHookExecutionStatus within one hour.
private static final Duration DEFAULT_HOOK_CALLBACK_TIMEOUT = Duration.ofHours(1);
private static final Duration STOP_POLL_INTERVAL = Duration.ofMillis(200);
private static final int ECS_READINESS_MAX_POLLS = 150;
private static final int ECS_TARGET_HEALTH_MAX_POLLS = 900;
@FunctionalInterface
interface Sleeper {
void sleep(long millis) throws InterruptedException;
}
private final LambdaService lambdaService;
private final S3Service s3Service;
private final EcsService ecsService;
private final ElbV2Service elbV2Service;
private final SsmCommandService ssmCommandService;
@@ -65,21 +86,48 @@ public class CodeDeployService {
private final StorageFactory storageFactory;
private final Duration hookCallbackTimeout;
private final Clock clock;
private final Sleeper sleeper;
@Inject
public CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory) {
StorageFactory storageFactory, S3Service s3Service) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC());
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC(), s3Service, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock) {
StorageFactory storageFactory) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, DEFAULT_HOOK_CALLBACK_TIMEOUT, Clock.systemUTC(), null, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, hookCallbackTimeout, clock, null, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock, S3Service s3Service) {
this(lambdaService, ecsService, elbV2Service, ssmCommandService, ec2Service, mapper,
regionResolver, storageFactory, hookCallbackTimeout, clock, s3Service, Thread::sleep);
}
CodeDeployService(LambdaService lambdaService, EcsService ecsService,
ElbV2Service elbV2Service, SsmCommandService ssmCommandService,
Ec2Service ec2Service, ObjectMapper mapper, RegionResolver regionResolver,
StorageFactory storageFactory, Duration hookCallbackTimeout, Clock clock, S3Service s3Service,
Sleeper sleeper) {
this.lambdaService = lambdaService;
this.s3Service = s3Service;
this.ecsService = ecsService;
this.elbV2Service = elbV2Service;
this.ssmCommandService = ssmCommandService;
@@ -90,6 +138,7 @@ public class CodeDeployService {
this.storageFactory = storageFactory;
this.hookCallbackTimeout = hookCallbackTimeout;
this.clock = clock;
this.sleeper = sleeper;
}
// ---- Durable (persisted) ----
@@ -109,8 +158,8 @@ public class CodeDeployService {
private final ConcurrentHashMap<String, ConcurrentHashMap<String, Deployment>> deployments = new ConcurrentHashMap<>();
// key: region -> deploymentId -> targetId -> DeploymentTarget wrapper
private final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<String, Map<String, Object>>>> deploymentTargets = new ConcurrentHashMap<>();
// key: lifecycleEventHookExecutionId -> CompletableFuture<status>
private final ConcurrentHashMap<String, CompletableFuture<String>> hookFutures = new ConcurrentHashMap<>();
// key: lifecycleEventHookExecutionId -> the deployment event that owns the callback
private final ConcurrentHashMap<String, HookExecution> hookExecutions = new ConcurrentHashMap<>();
// key: deploymentId -> stop flag
private final ConcurrentHashMap<String, AtomicBoolean> stopFlags = new ConcurrentHashMap<>();
@@ -203,6 +252,28 @@ public class CodeDeployService {
String afterAllowTraffic;
}
private static final class HookExecution {
final String deploymentId;
final String lifecycleEventName;
final CompletableFuture<String> future;
HookExecution(String deploymentId, String lifecycleEventName, CompletableFuture<String> future) {
this.deploymentId = deploymentId;
this.lifecycleEventName = lifecycleEventName;
this.future = future;
}
}
private static final class AliasRoutingSnapshot {
final String functionVersion;
final Map<String, Double> routingConfig;
AliasRoutingSnapshot(LambdaAlias alias) {
this.functionVersion = alias.getFunctionVersion();
this.routingConfig = alias.getRoutingConfig() == null ? Map.of() : Map.copyOf(alias.getRoutingConfig());
}
}
private static final class ServerAppSpecInfo {
String os;
Map<String, List<Map<String, Object>>> hooks; // hookName → [{location, timeout, runas}]
@@ -578,8 +649,10 @@ public class CodeDeployService {
return createServerDeployment(region, appName, groupName, group, configName, revision, description);
}
AppSpecInfo appSpec = parseAppSpec(revision);
String effectiveConfig = configName != null ? configName : group.getDeploymentConfigName();
validateLambdaDeployment(region, app, group, effectiveConfig);
AppSpecInfo appSpec = parseAppSpec(revision);
validateLambdaTarget(region, appSpec);
String deploymentId = generateDeploymentId();
double now = Instant.now().toEpochMilli() / 1000.0;
@@ -695,10 +768,16 @@ public class CodeDeployService {
}
public String putLifecycleEventHookExecutionStatus(String deploymentId, String executionId, String status) {
CompletableFuture<String> future = hookFutures.get(executionId);
if (future != null && !future.isDone()) {
future.complete(status);
if (!"Succeeded".equals(status) && !"Failed".equals(status)) {
throw new AwsException("InvalidLifecycleEventHookExecutionStatusException",
"Lifecycle event hook execution status must be Succeeded or Failed", 400);
}
HookExecution execution = hookExecutions.get(executionId);
if (execution == null || !execution.deploymentId.equals(deploymentId) || execution.future.isDone()) {
throw new AwsException("InvalidLifecycleEventHookExecutionIdException",
"Lifecycle event hook execution ID is not valid for this deployment", 400);
}
execution.future.complete(status);
return executionId;
}
@@ -1063,12 +1142,17 @@ public class CodeDeployService {
for (Map<String, String> filter : ec2TagFilters) {
String key = filter.get("Key");
String value = filter.get("Value");
if (key != null && value != null) {
String type = filter.get("Type");
if ("KEY_ONLY".equals(type) && key != null) {
filters.computeIfAbsent("tag-key", k -> new ArrayList<>()).add(key);
} else if ("VALUE_ONLY".equals(type) && value != null) {
filters.computeIfAbsent("tag-value", k -> new ArrayList<>()).add(value);
} else if (key != null && value != null) {
filters.computeIfAbsent("tag:" + key, k -> new ArrayList<>()).add(value);
}
}
try {
ec2Service.describeInstances(region, null, filters).stream()
ec2Service.describeInstances(region, List.of(), filters).stream()
.flatMap(r -> r.getInstances().stream())
.map(inst -> inst.getInstanceId())
.filter(id -> id != null)
@@ -1118,38 +1202,53 @@ public class CodeDeployService {
if (revision == null) {
throw new AwsException("InvalidRevisionException", "Revision is required", 400);
}
Object appSpecContent = revision.get("appSpecContent");
if (!(appSpecContent instanceof Map)) {
throw new AwsException("InvalidRevisionException", "Missing appSpecContent in revision", 400);
}
String content = (String) ((Map<String, Object>) appSpecContent).get("content");
String content = appSpecContent(revision);
if (content == null || content.isBlank()) {
throw new AwsException("InvalidRevisionException", "Missing content in appSpecContent", 400);
}
AppSpecInfo info = new AppSpecInfo();
try {
JsonNode root = mapper.readTree(content);
JsonNode resources = root.get("Resources");
if (resources != null && resources.isArray() && !resources.isEmpty()) {
JsonNode firstResource = resources.get(0);
if (firstResource.isObject()) {
JsonNode resourceNode = firstResource.fields().next().getValue();
JsonNode props = resourceNode.path("Properties");
info.functionName = props.path("Name").asText(null);
info.aliasName = props.path("Alias").asText(null);
info.currentVersion = props.path("CurrentVersion").asText(null);
info.targetVersion = props.path("TargetVersion").asText(null);
}
JsonNode root = yamlMapper.readTree(content);
if (!root.isObject() || !"0.0".equals(root.path("version").asText())) {
throw new IllegalArgumentException("AppSpec version must be 0.0");
}
JsonNode resources = root.path("Resources");
if (!resources.isArray() || resources.size() != 1 || !resources.get(0).isObject()
|| resources.get(0).size() != 1) {
throw new IllegalArgumentException("Lambda AppSpec must contain exactly one resource");
}
JsonNode resourceNode = resources.get(0).elements().next();
if (!"AWS::Lambda::Function".equals(resourceNode.path("Type").asText())) {
throw new IllegalArgumentException("Lambda AppSpec resource Type must be AWS::Lambda::Function");
}
JsonNode props = resourceNode.path("Properties");
info.functionName = requiredAppSpecText(props, "Name");
info.aliasName = requiredAppSpecText(props, "Alias");
info.currentVersion = requiredAppSpecText(props, "CurrentVersion");
info.targetVersion = requiredAppSpecText(props, "TargetVersion");
if (info.currentVersion.equals(info.targetVersion)) {
throw new IllegalArgumentException("CurrentVersion and TargetVersion must differ");
}
JsonNode hooks = root.get("Hooks");
if (hooks != null && hooks.isArray()) {
if (hooks != null) {
if (!hooks.isArray()) {
throw new IllegalArgumentException("Hooks must be an array");
}
for (JsonNode hook : hooks) {
if (hook.has("BeforeAllowTraffic")) {
info.beforeAllowTraffic = hook.get("BeforeAllowTraffic").asText(null);
if (!hook.isObject() || hook.size() != 1) {
throw new IllegalArgumentException("Each Hook must contain exactly one lifecycle event");
}
if (hook.has("AfterAllowTraffic")) {
info.afterAllowTraffic = hook.get("AfterAllowTraffic").asText(null);
Map.Entry<String, JsonNode> entry = hook.fields().next();
String hookFunction = requiredAppSpecText(hook, entry.getKey());
if ("BeforeAllowTraffic".equals(entry.getKey())) {
if (info.beforeAllowTraffic != null) { throw new IllegalArgumentException("Duplicate BeforeAllowTraffic hook"); }
info.beforeAllowTraffic = hookFunction;
} else if ("AfterAllowTraffic".equals(entry.getKey())) {
if (info.afterAllowTraffic != null) { throw new IllegalArgumentException("Duplicate AfterAllowTraffic hook"); }
info.afterAllowTraffic = hookFunction;
} else {
throw new IllegalArgumentException("Unsupported Lambda lifecycle event: " + entry.getKey());
}
}
}
@@ -1157,13 +1256,73 @@ public class CodeDeployService {
throw new AwsException("InvalidRevisionException", "Failed to parse AppSpec content: " + e.getMessage(), 400);
}
if (info.functionName == null || info.aliasName == null || info.targetVersion == null) {
throw new AwsException("InvalidRevisionException",
"AppSpec must specify Name, Alias, and TargetVersion", 400);
}
return info;
}
@SuppressWarnings("unchecked")
private String appSpecContent(Map<String, Object> revision) {
Object appSpecContent = revision.get("appSpecContent");
if (appSpecContent instanceof Map<?, ?> content) {
Object value = ((Map<String, Object>) content).get("content");
if (value instanceof String text && !text.isBlank()) {
return text;
}
}
Object s3Location = revision.get("s3Location");
if (!(s3Location instanceof Map<?, ?> location) || s3Service == null) {
throw new AwsException("InvalidRevisionException", "Missing AppSpec content in revision", 400);
}
String bucket = String.valueOf(location.get("bucket"));
String key = String.valueOf(location.get("key"));
byte[] artifact = s3Service.getObject(bucket, key).getData();
InputStream input = artifact == null ? InputStream.nullInputStream() : new ByteArrayInputStream(artifact);
try (ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if (!entry.isDirectory() && ("appspec.yml".equalsIgnoreCase(name) || "appspec.yaml".equalsIgnoreCase(name))) {
return new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
}
} catch (Exception e) {
throw new AwsException("InvalidRevisionException", "Could not read AppSpec from S3 revision: " + e.getMessage(), 400);
}
throw new AwsException("InvalidRevisionException", "S3 revision does not contain appspec.yml or appspec.yaml", 400);
}
private String requiredAppSpecText(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || !value.isTextual() || value.asText().isBlank()) {
throw new IllegalArgumentException("AppSpec must specify a non-empty " + field);
}
return value.asText();
}
private void validateLambdaDeployment(String region, Application app, DeploymentGroup group, String configName) {
if (!"Lambda".equals(app.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Application compute platform must be Lambda", 400);
}
if (group.getComputePlatform() != null && !"Lambda".equals(group.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Deployment group compute platform must be Lambda", 400);
}
DeploymentConfig config = getDeploymentConfig(region, configName);
if (!"Lambda".equals(config.getComputePlatform())) {
throw new AwsException("InvalidDeploymentConfigException", "Deployment configuration compute platform must be Lambda", 400);
}
}
private void validateLambdaTarget(String region, AppSpecInfo appSpec) {
LambdaAlias alias = lambdaService.getAlias(region, appSpec.functionName, appSpec.aliasName);
if (!appSpec.currentVersion.equals(alias.getFunctionVersion())) {
throw new AwsException("InvalidRevisionException", "AppSpec CurrentVersion does not match the alias version", 400);
}
List<String> versions = lambdaService.listVersionsByFunction(region, appSpec.functionName).stream()
.map(LambdaFunction::getVersion).toList();
if (!versions.contains(appSpec.currentVersion) || !versions.contains(appSpec.targetVersion)) {
throw new AwsException("InvalidRevisionException", "AppSpec version does not exist", 400);
}
}
@SuppressWarnings("unchecked")
private EcsAppSpecInfo parseEcsAppSpec(Map<String, Object> revision) {
if (revision == null) {
@@ -1180,7 +1339,13 @@ public class CodeDeployService {
EcsAppSpecInfo info = new EcsAppSpecInfo();
try {
JsonNode root = mapper.readTree(content);
JsonNode root;
try {
root = mapper.readTree(content);
} catch (Exception ignored) {
// CodePipeline's AppSpecTemplatePath conventionally points to YAML.
root = yamlMapper.readTree(content);
}
JsonNode resources = root.get("Resources");
if (resources != null && resources.isArray() && !resources.isEmpty()) {
JsonNode firstResource = resources.get(0);
@@ -1367,7 +1532,28 @@ public class CodeDeployService {
// its startedBy, which is how a client tells a CodeDeploy set from an external one.
createTaskSet.setExternalId(deploymentId);
createTaskSet.setStartedBy("CODE_DEPLOY");
EcsServiceModel targetService = ecsService.describeServices(clusterName, List.of(serviceName), region)
.getFirst();
if (targetService.getCapacityProviderStrategy() != null
&& !targetService.getCapacityProviderStrategy().isEmpty()) {
createTaskSet.setCapacityProviderStrategy(targetService.getCapacityProviderStrategy());
} else {
createTaskSet.setLaunchType(targetService.getLaunchType());
}
if (greenTgArn != null) {
EcsLoadBalancer loadBalancer = new EcsLoadBalancer();
loadBalancer.setTargetGroupArn(greenTgArn);
loadBalancer.setContainerName(appSpec.containerName);
loadBalancer.setContainerPort(appSpec.containerPort);
createTaskSet.setLoadBalancers(List.of(loadBalancer));
}
greenTaskSet = ecsService.createTaskSet(createTaskSet, region);
verifyGreenTaskSet(region, clusterName, serviceName, deploymentId, targetService, greenTaskSet,
greenTgArn);
if (greenTaskSet.getLaunchType() == LaunchType.FARGATE) {
greenTaskSet = waitForGreenTaskSetRunning(region, clusterName, serviceName, greenTaskSet,
stopFlag);
}
appendTaskSetInfo(ecsTargetMap, greenTaskSet, greenTgArn, 0.0);
finishLifecycleEvent(installEvent, "Succeeded");
} catch (Exception e) {
@@ -1406,11 +1592,13 @@ public class CodeDeployService {
// AllowTraffic: shift ELB traffic blue → green
if (!listenerArns.isEmpty() && blueTgArn != null && greenTgArn != null) {
waitForGreenTargets(region, greenTgArn, stopFlag);
if (stopFlag.get()) { finishEcsStopped(deployment, ecsTargetMap); return; }
executeEcsAllowTraffic(region, deployment, configName, ecsTargetMap,
listenerArns, blueTgArn, greenTgArn, stopFlag);
verifyListenersForwardToGreen(region, listenerArns, greenTgArn);
} else {
Map<String, Object> allowEvent = addLifecycleEvent(ecsTargetMap, "AllowTraffic");
finishLifecycleEvent(allowEvent, "Succeeded");
throw new IllegalStateException("ECS deployment group must have production listeners and blue/green target groups");
}
if (stopFlag.get()) { finishEcsStopped(deployment, ecsTargetMap); return; }
@@ -1438,10 +1626,11 @@ public class CodeDeployService {
try {
ecsService.deleteTaskSet(clusterName, serviceName,
blueTaskSet.getTaskSetArn(), true, region);
verifyBlueTaskSetDrained(region, clusterName, serviceName, blueTaskSet.getTaskSetArn());
finishLifecycleEvent(terminateEvent, "Succeeded");
} catch (Exception e) {
LOG.debugv("Could not delete blue task set: {0}", e.getMessage());
finishLifecycleEvent(terminateEvent, "Succeeded");
finishLifecycleEvent(terminateEvent, "Failed");
throw e;
}
}
@@ -1460,10 +1649,126 @@ public class CodeDeployService {
}
}
private void executeEcsAllowTraffic(String region, Deployment deployment, String configName,
Map<String, Object> ecsTargetMap, List<String> listenerArns,
String blueTgArn, String greenTgArn,
AtomicBoolean stopFlag) throws InterruptedException {
void verifyGreenTaskSet(String region, String clusterName, String serviceName, String deploymentId,
EcsServiceModel targetService, TaskSet createdTaskSet, String greenTgArn) {
if (createdTaskSet == null || createdTaskSet.getTaskSetArn() == null) {
throw new IllegalStateException("ECS did not create a green task set");
}
List<TaskSet> taskSets = ecsService.describeTaskSets(clusterName, serviceName,
List.of(createdTaskSet.getTaskSetArn()), region);
if (taskSets.size() != 1) {
throw new IllegalStateException("ECS did not retain the green task set");
}
TaskSet greenTaskSet = taskSets.getFirst();
if (!deploymentId.equals(greenTaskSet.getExternalId())
|| !"CODE_DEPLOY".equals(greenTaskSet.getStartedBy())
|| !"ACTIVE".equals(greenTaskSet.getStatus())) {
throw new IllegalStateException("ECS green task set is not a CodeDeploy active task set");
}
if (greenTaskSet.getLaunchType() != LaunchType.FARGATE && greenTaskSet.getLaunchType() != LaunchType.EC2) {
throw new IllegalStateException("ECS green task set does not use FARGATE or EC2");
}
if (targetService.getLaunchType() != null && greenTaskSet.getLaunchType() != targetService.getLaunchType()) {
throw new IllegalStateException("ECS green task set launch type does not match the service");
}
if (greenTaskSet.getLaunchType() == LaunchType.EC2
&& !hasRunningTasks(greenTaskSet)) {
throw new IllegalStateException("ECS green task set has no running tasks");
}
if (greenTgArn == null || greenTaskSet.getLoadBalancers() == null || greenTaskSet.getLoadBalancers().stream()
.noneMatch(loadBalancer -> greenTgArn.equals(loadBalancer.getTargetGroupArn()))) {
throw new IllegalStateException("ECS green task set is not registered with the green target group");
}
}
/** Fargate task launch is asynchronous, so the initial CreateTaskSet result is not yet stable. */
TaskSet waitForGreenTaskSetRunning(String region, String clusterName, String serviceName,
TaskSet createdTaskSet, AtomicBoolean stopFlag) throws InterruptedException {
for (int poll = 0; poll < ECS_READINESS_MAX_POLLS && !stopFlag.get(); poll++) {
List<TaskSet> taskSets = ecsService.describeTaskSets(clusterName, serviceName,
List.of(createdTaskSet.getTaskSetArn()), region);
if (taskSets.size() == 1 && hasRunningTasks(taskSets.getFirst())) {
return taskSets.getFirst();
}
sleeper.sleep(STOP_POLL_INTERVAL.toMillis());
}
if (!stopFlag.get()) {
throw new IllegalStateException("ECS green task set has no running tasks");
}
return createdTaskSet;
}
private boolean hasRunningTasks(TaskSet taskSet) {
return taskSet.getComputedDesiredCount() > 0
&& taskSet.getRunningCount() >= taskSet.getComputedDesiredCount();
}
/** A production listener is switched only once green tasks are registered and routable. */
void waitForGreenTargets(String region, String greenTgArn, AtomicBoolean stopFlag)
throws InterruptedException {
boolean registeredTargets = false;
for (int poll = 0; poll < ECS_TARGET_HEALTH_MAX_POLLS && !stopFlag.get(); poll++) {
List<TargetHealth> targets = elbV2Service.describeTargetHealth(region, greenTgArn, null);
registeredTargets = !targets.isEmpty();
if (registeredTargets && targets.stream().allMatch(target -> "healthy".equals(target.getState()))) {
return;
}
sleeper.sleep(STOP_POLL_INTERVAL.toMillis());
}
if (!stopFlag.get()) {
throw new IllegalStateException(registeredTargets
? "ECS green target group has unhealthy targets"
: "ECS green target group has no registered targets");
}
}
void verifyListenersForwardToGreen(String region, List<String> listenerArns, String greenTgArn) {
for (String listenerArn : listenerArns) {
List<Rule> defaultRules = elbV2Service.describeRules(region, listenerArn, null).stream()
.filter(Rule::isDefault)
.toList();
if (defaultRules.size() != 1 || !forwardsOnlyTo(defaultRules.getFirst().getActions(), greenTgArn)) {
throw new IllegalStateException("Production listener did not switch to the green target group");
}
}
}
private boolean forwardsOnlyTo(List<Action> actions, String greenTgArn) {
if (actions == null) {
return false;
}
List<Action> forwardActions = actions.stream()
.filter(action -> "forward".equals(action.getType()))
.toList();
return !forwardActions.isEmpty()
&& forwardActions.stream().allMatch(action -> forwardsOnlyTo(action, greenTgArn));
}
private boolean forwardsOnlyTo(Action action, String greenTgArn) {
if (action.getTargetGroups() == null || action.getTargetGroups().isEmpty()) {
return greenTgArn.equals(action.getTargetGroupArn());
}
boolean greenReceivesTraffic = action.getTargetGroups().stream()
.anyMatch(target -> greenTgArn.equals(target.getTargetGroupArn()) && effectiveWeight(target) > 0);
boolean otherTargetReceivesTraffic = action.getTargetGroups().stream()
.anyMatch(target -> !greenTgArn.equals(target.getTargetGroupArn()) && effectiveWeight(target) > 0);
return greenReceivesTraffic && !otherTargetReceivesTraffic;
}
private int effectiveWeight(Action.TargetGroupTuple target) {
return target.getWeight() != null ? target.getWeight() : 1;
}
void verifyBlueTaskSetDrained(String region, String clusterName, String serviceName, String blueTaskSetArn) {
if (!ecsService.describeTaskSets(clusterName, serviceName, List.of(blueTaskSetArn), region).isEmpty()) {
throw new IllegalStateException("ECS blue task set was not drained");
}
}
void executeEcsAllowTraffic(String region, Deployment deployment, String configName,
Map<String, Object> ecsTargetMap, List<String> listenerArns,
String blueTgArn, String greenTgArn,
AtomicBoolean stopFlag) throws InterruptedException {
TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName);
Map<String, Object> event = addLifecycleEvent(ecsTargetMap, "AllowTraffic");
try {
@@ -1473,9 +1778,9 @@ public class CodeDeployService {
elbV2Service.shiftListenerForward(region, listenerArn,
blueTgArn, greenTgArn, trc.percentage);
}
long waitMs = Math.min(trc.intervalSeconds * 1000L, 5000L);
long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0 && !stopFlag.get()) {
Thread.sleep(waitMs);
sleeper.sleep(waitMs);
}
if (!stopFlag.get()) {
for (String listenerArn : listenerArns) {
@@ -1493,9 +1798,9 @@ public class CodeDeployService {
blueTgArn, greenTgArn, pct);
}
if (pct < 100) {
long waitMs = Math.min(trc.intervalSeconds * 1000L, 2000L);
long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0) {
Thread.sleep(waitMs);
sleeper.sleep(waitMs);
}
}
}
@@ -1588,6 +1893,9 @@ public class CodeDeployService {
private void runStateMachine(String region, Deployment deployment, AppSpecInfo appSpec,
Map<String, Object> lambdaTargetMap, AtomicBoolean stopFlag, String configName) {
String deploymentId = deployment.getDeploymentId();
AliasRoutingSnapshot aliasSnapshot = new AliasRoutingSnapshot(
lambdaService.getAlias(region, appSpec.functionName, appSpec.aliasName));
boolean trafficShifted = false;
try {
deployment.setStatus("InProgress");
deployment.setStartTime(Instant.now().toEpochMilli() / 1000.0);
@@ -1605,16 +1913,30 @@ public class CodeDeployService {
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; }
// Once AllowTraffic starts, a failed partial shift must be restored from the captured snapshot.
trafficShifted = true;
executeAllowTraffic(region, deployment, appSpec, configName, lambdaTargetMap, stopFlag);
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; }
if (stopFlag.get()) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
finishStopped(deployment, lambdaTargetMap);
return;
}
if (appSpec.afterAllowTraffic != null) {
boolean ok = invokeHook(region, deployment, appSpec.afterAllowTraffic,
"AfterAllowTraffic", lambdaTargetMap, stopFlag);
if (stopFlag.get()) { finishStopped(deployment, lambdaTargetMap); return; }
if (!ok) { finishFailed(deployment, lambdaTargetMap, "HOOK_EXECUTION_FAILURE",
"Hook function reported failure: AfterAllowTraffic"); return; }
if (stopFlag.get()) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
finishStopped(deployment, lambdaTargetMap);
return;
}
if (!ok) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_FAILURE");
finishFailed(deployment, lambdaTargetMap, "HOOK_EXECUTION_FAILURE",
"Hook function reported failure: AfterAllowTraffic");
return;
}
}
updateTargetStatus(lambdaTargetMap, "Succeeded");
@@ -1623,18 +1945,24 @@ public class CodeDeployService {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (trafficShifted) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_STOP_ON_REQUEST");
}
finishStopped(deployment, lambdaTargetMap);
} catch (Exception e) {
LOG.warnv("Deployment {0} failed: {1}", deploymentId, e.getMessage());
if (trafficShifted) {
rollbackAliasIfEnabled(region, deployment, appSpec, aliasSnapshot, "DEPLOYMENT_FAILURE");
}
finishFailed(deployment, lambdaTargetMap, "DeploymentFailed", e.getMessage());
} finally {
stopFlags.remove(deploymentId);
}
}
private void executeAllowTraffic(String region, Deployment deployment, AppSpecInfo appSpec,
String configName, Map<String, Object> lambdaTargetMap,
AtomicBoolean stopFlag) throws InterruptedException {
private boolean executeAllowTraffic(String region, Deployment deployment, AppSpecInfo appSpec,
String configName, Map<String, Object> lambdaTargetMap,
AtomicBoolean stopFlag) throws InterruptedException {
TrafficRoutingInfo trc = getTrafficRoutingInfo(region, configName);
Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, "AllowTraffic");
@@ -1647,12 +1975,11 @@ public class CodeDeployService {
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
appSpec.currentVersion, null, routing);
// Wait the canary interval (capped at 5s for emulator speed)
long waitMs = Math.min(trc.intervalSeconds * 1000L, 5000L);
long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0 && !stopFlag.get()) {
Thread.sleep(waitMs);
sleeper.sleep(waitMs);
}
if (stopFlag.get()) { return; }
if (stopFlag.get()) { return true; }
// Step 2: flip 100% to targetVersion
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
@@ -1669,9 +1996,9 @@ public class CodeDeployService {
double weight = pct / 100.0;
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
appSpec.currentVersion, null, Map.of(appSpec.targetVersion, weight));
long waitMs = Math.min(trc.intervalSeconds * 1000L, 2000L);
long waitMs = trc.intervalSeconds * 1000L;
if (waitMs > 0) {
Thread.sleep(waitMs);
sleeper.sleep(waitMs);
}
}
}
@@ -1683,18 +2010,35 @@ public class CodeDeployService {
}
}
finishLifecycleEvent(event, "Succeeded");
return true;
} catch (Exception e) {
finishLifecycleEvent(event, "Failed");
throw e;
}
}
@SuppressWarnings("unchecked")
private void rollbackAliasIfEnabled(String region, Deployment deployment, AppSpecInfo appSpec,
AliasRoutingSnapshot snapshot, String event) {
DeploymentGroup group = getDeploymentGroup(region, deployment.getApplicationName(), deployment.getDeploymentGroupName());
Map<String, Object> config = group.getAutoRollbackConfiguration();
if (config == null || !Boolean.TRUE.equals(config.get("enabled"))) {
return;
}
Object events = config.get("events");
if (!(events instanceof List<?> list) || !list.contains(event)) {
return;
}
lambdaService.updateAlias(region, appSpec.functionName, appSpec.aliasName,
snapshot.functionVersion, null, snapshot.routingConfig);
}
private boolean invokeHook(String region, Deployment deployment, String hookFunctionName,
String lifecycleEventName, Map<String, Object> lambdaTargetMap,
AtomicBoolean stopFlag) throws InterruptedException {
String executionId = UUID.randomUUID().toString();
CompletableFuture<String> future = new CompletableFuture<>();
hookFutures.put(executionId, future);
hookExecutions.put(executionId, new HookExecution(deployment.getDeploymentId(), lifecycleEventName, future));
Map<String, Object> event = addLifecycleEvent(lambdaTargetMap, lifecycleEventName);
@@ -1757,7 +2101,7 @@ public class CodeDeployService {
}
return "Succeeded".equals(status);
} finally {
hookFutures.remove(executionId);
hookExecutions.remove(executionId);
}
}
@@ -12,6 +12,9 @@ import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.codebuild.CodeBuildService;
import io.github.hectorvent.floci.services.codebuild.model.Build;
import io.github.hectorvent.floci.services.codebuild.model.ProjectArtifacts;
import io.github.hectorvent.floci.services.cloudformation.CloudFormationService;
import io.github.hectorvent.floci.services.cloudformation.model.Stack;
import io.github.hectorvent.floci.services.codedeploy.CodeDeployService;
import io.github.hectorvent.floci.services.codedeploy.model.Deployment;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineExecution;
@@ -19,6 +22,10 @@ import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineExecut
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelinePipeline.TransitionState;
import io.github.hectorvent.floci.services.codepipeline.model.CodePipelineStoredItem;
import io.github.hectorvent.floci.services.ecs.EcsService;
import io.github.hectorvent.floci.services.ecs.model.ContainerDefinition;
import io.github.hectorvent.floci.services.ecs.model.RegisterTaskDefinitionRequest;
import io.github.hectorvent.floci.services.ecs.model.TaskDefinition;
import io.github.hectorvent.floci.services.lambda.LambdaService;
import io.github.hectorvent.floci.services.lambda.model.InvocationType;
import io.github.hectorvent.floci.services.lambda.model.InvokeResult;
@@ -30,6 +37,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
@@ -42,12 +50,15 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@ApplicationScoped
public class CodePipelineService {
@@ -59,6 +70,8 @@ public class CodePipelineService {
private static final long SOURCE_POLL_INTERVAL_MS = 500L;
private static final String SOURCE_POLL_TYPE = "source-poll";
private static final String MISSING_SOURCE_REVISION = "missing";
private static final int MAX_ARTIFACT_ENTRY_SIZE = 256 * 1024 * 1024;
private static final int MAX_EXTRACTED_ARTIFACT_SIZE = 256 * 1024 * 1024;
private final AccountAwareStorageBackend<CodePipelinePipeline> pipelineStore;
private final AccountAwareStorageBackend<CodePipelineExecution> executionStore;
@@ -66,6 +79,8 @@ public class CodePipelineService {
private final ObjectMapper mapper;
private final CodeBuildService codeBuildService;
private final CodeDeployService codeDeployService;
private final EcsService ecsService;
private final CloudFormationService cloudFormationService;
private final LambdaService lambdaService;
private final S3Service s3Service;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
@@ -76,13 +91,13 @@ public class CodePipelineService {
// monitor for its whole run, so counting under that monitor would block StartPipelineExecution
// until the running execution finished.
private final KeyedLockPool startLocks = new KeyedLockPool();
private final Map<String, byte[]> runtimeArtifacts = new ConcurrentHashMap<>();
@Inject
@SuppressWarnings("unchecked")
public CodePipelineService(StorageFactory storageFactory, ObjectMapper mapper,
CodeBuildService codeBuildService, CodeDeployService codeDeployService,
LambdaService lambdaService, S3Service s3Service) {
CodeBuildService codeBuildService, CodeDeployService codeDeployService,
LambdaService lambdaService, S3Service s3Service,
CloudFormationService cloudFormationService, EcsService ecsService) {
this.pipelineStore = storageFactory.create(
"codepipeline", "codepipeline-pipelines.json", new TypeReference<Map<String, CodePipelinePipeline>>() {});
this.executionStore = storageFactory.create(
@@ -92,10 +107,20 @@ public class CodePipelineService {
this.mapper = mapper;
this.codeBuildService = codeBuildService;
this.codeDeployService = codeDeployService;
this.ecsService = ecsService;
this.cloudFormationService = cloudFormationService;
this.lambdaService = lambdaService;
this.s3Service = s3Service;
}
CodePipelineService(StorageFactory storageFactory, ObjectMapper mapper,
CodeBuildService codeBuildService, CodeDeployService codeDeployService,
LambdaService lambdaService, S3Service s3Service,
CloudFormationService cloudFormationService) {
this(storageFactory, mapper, codeBuildService, codeDeployService, lambdaService, s3Service,
cloudFormationService, null);
}
public JsonNode handle(String action, JsonNode request, String region, String account) {
return switch (action) {
case "CreatePipeline" -> createPipeline(request, region, account);
@@ -745,22 +770,82 @@ public class CodePipelineService {
String pipelineName = text(request, "pipelineName");
CodePipelineExecution source = requireExecution(
account, region, pipelineName, text(request, "pipelineExecutionId"));
ObjectNode start = mapper.createObjectNode();
start.put("name", pipelineName);
start.set("sourceRevisions", mapper.valueToTree(source.getSourceRevisions()));
ArrayNode vars = start.putArray("variables");
source.getVariables().forEach(v -> vars.addObject()
.put("name", v.get("name"))
.put("value", v.get("resolvedValue")));
return startPipelineExecution(start, region, account);
CodePipelinePipeline pipeline = requirePipeline(account, region, pipelineName);
String stageName = text(request, "stageName");
String retryMode = text(request, "retryMode");
requireStage(pipeline, stageName);
if (!List.of("FAILED_ACTIONS", "ALL_ACTIONS").contains(retryMode)) {
throw new AwsException("ValidationException", "retryMode must be FAILED_ACTIONS or ALL_ACTIONS", 400);
}
if (!"Failed".equals(stageExecutionStatus(source, stageName))) {
throw new AwsException("PipelineExecutionNotRetryableException",
"Stage execution is not failed: " + stageName, 400);
}
List<String> actionNames = new ArrayList<>();
for (JsonNode stage : pipeline.getDeclaration().path("stages")) {
if (!stageName.equals(stage.path("name").asText())) {
continue;
}
for (JsonNode action : stage.path("actions")) {
String actionName = action.path("name").asText();
if ("ALL_ACTIONS".equals(retryMode) || source.getActionExecutions().stream()
.anyMatch(a -> stageName.equals(a.getStageName()) && actionName.equals(a.getActionName())
&& "Failed".equals(a.getStatus()))) {
actionNames.add(actionName);
}
}
}
if (actionNames.isEmpty()) {
throw new AwsException("PipelineExecutionNotRetryableException",
"Stage execution has no failed actions: " + stageName, 400);
}
CodePipelineExecution retry = new CodePipelineExecution();
retry.setAccountId(account);
retry.setRegion(region);
retry.setPipelineExecutionId(UUID.randomUUID().toString());
retry.setPipelineName(pipelineName);
retry.setPipelineVersion(pipeline.getVersion());
retry.setExecutionMode(pipeline.getDeclaration().path("executionMode").asText(DEFAULT_EXECUTION_MODE));
retry.setExecutionType("STANDARD");
retry.setStatus("InProgress");
retry.setStatusSummary("Stage execution retry started.");
retry.setStartTime(now());
retry.setLastUpdateTime(retry.getStartTime());
retry.setSourceRevisions(new ArrayList<>(source.getSourceRevisions()));
retry.setVariables(new ArrayList<>(source.getVariables()));
retry.setArtifacts(new LinkedHashMap<>(source.getArtifacts()));
retry.setRetryStageName(stageName);
retry.setRetryActionNames(actionNames);
retry.setTrigger(Map.of("triggerType", "RetryStageExecution", "triggerDetail", stageName));
if (!persistExecutionIfSlotAvailable(retry)) {
throw new AwsException("ConcurrentPipelineExecutionsLimitExceededException",
"The pipeline has reached the limit for concurrent pipeline executions", 400);
}
applyExecutionMode(retry);
try {
executor.submit(() -> runExecution(pipeline, retry));
} catch (RejectedExecutionException exception) {
retry.setStatus("Failed");
retry.setStatusSummary("Pipeline execution could not be scheduled.");
retry.setLastUpdateTime(now());
putExecution(retry);
throw new AwsException("ConflictException", "Pipeline execution could not be scheduled.", 400);
}
return mapper.createObjectNode().put("pipelineExecutionId", retry.getPipelineExecutionId());
}
private ObjectNode rollbackStage(JsonNode request, String region, String account) {
CodePipelineExecution target = requireExecution(
account, region, text(request, "pipelineName"), text(request, "targetPipelineExecutionId"));
ObjectNode started = retryStageExecution(mapper.createObjectNode()
.put("pipelineName", target.getPipelineName())
.put("pipelineExecutionId", target.getPipelineExecutionId()), region, account);
ObjectNode start = mapper.createObjectNode().put("name", target.getPipelineName());
start.set("sourceRevisions", mapper.valueToTree(target.getSourceRevisions()));
ArrayNode vars = start.putArray("variables");
target.getVariables().forEach(v -> vars.addObject()
.put("name", v.get("name"))
.put("value", v.get("resolvedValue")));
ObjectNode started = startPipelineExecution(start, region, account);
CodePipelineExecution rollback = requireExecution(
account, region, target.getPipelineName(), started.path("pipelineExecutionId").asText());
rollback.setExecutionType("ROLLBACK");
@@ -977,6 +1062,10 @@ public class CodePipelineService {
Runnable work = () -> {
try {
for (JsonNode stage : pipeline.getDeclaration().path("stages")) {
if (execution.getRetryStageName() != null
&& !execution.getRetryStageName().equals(stage.path("name").asText())) {
continue;
}
waitForTransition(pipeline, execution, stage.path("name").asText());
if (finishIfStopped(execution)) {
return;
@@ -986,7 +1075,7 @@ public class CodePipelineService {
execution.getStageExecutionStatuses().put(stageName, "InProgress");
execution.setLastUpdateTime(now());
putExecution(execution);
runStage(pipeline, execution, stage);
runStage(pipeline, execution, stage, execution.getRetryActionNames());
if ("Failed".equals(execution.getStatus())) {
execution.getStageExecutionStatuses().put(stageName, "Failed");
return;
@@ -1012,7 +1101,6 @@ public class CodePipelineService {
execution.setCurrentStage(null);
execution.setLastUpdateTime(now());
putExecution(execution);
clearRuntimeArtifacts(execution);
}
};
if ("QUEUED".equals(execution.getExecutionMode())) {
@@ -1022,9 +1110,14 @@ public class CodePipelineService {
}
}
private void runStage(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode stage) {
private void runStage(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode stage,
List<String> retryActionNames) {
Map<Integer, List<JsonNode>> groups = new LinkedHashMap<>();
for (JsonNode action : stage.path("actions")) {
if (execution.getRetryStageName() != null
&& !retryActionNames.contains(action.path("name").asText())) {
continue;
}
groups.computeIfAbsent(action.path("runOrder").asInt(1), ignored -> new ArrayList<>()).add(action);
}
groups.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
@@ -1098,7 +1191,9 @@ public class CodePipelineService {
switch (provider) {
case "S3" -> executeS3(execution, action, state);
case "CodeBuild" -> executeCodeBuild(execution, action, state);
case "CodeDeploy" -> executeCodeDeploy(execution, action, state);
case "CodeDeploy" -> executeCodeDeploy(pipeline, execution, action, state);
case "CodeDeployToECS" -> executeCodeDeployToEcs(execution, action, state);
case "CloudFormation" -> executeCloudFormation(pipeline, execution, action, state);
case "Lambda" -> executeLambda(execution, action, state);
case "CodePipeline" -> executeNestedPipeline(execution, action, state);
default -> throw new AwsException("InvalidActionDeclarationException",
@@ -1113,7 +1208,7 @@ public class CodePipelineService {
String key = config.path("S3ObjectKey").asText(config.path("ObjectKey").asText(null));
S3Object object = s3Service.getObject(bucket, key);
for (JsonNode artifact : action.path("outputArtifacts")) {
runtimeArtifacts.put(artifactKey(execution, artifact.path("name").asText()), object.getData());
execution.getArtifacts().put(artifact.path("name").asText(), object.getData());
}
Map<String, Object> revision = new LinkedHashMap<>();
revision.put("name", state.getActionName());
@@ -1129,7 +1224,7 @@ public class CodePipelineService {
String bucket = config.path("BucketName").asText(config.path("S3Bucket").asText(null));
String objectKey = config.path("ObjectKey").asText(state.getActionName() + ".zip");
JsonNode input = action.path("inputArtifacts").path(0);
byte[] data = runtimeArtifacts.get(artifactKey(execution, input.path("name").asText()));
byte[] data = execution.getArtifacts().get(input.path("name").asText());
if (data == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available", 400);
}
@@ -1139,9 +1234,14 @@ public class CodePipelineService {
private void executeCodeBuild(CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException {
for (JsonNode artifact : action.path("outputArtifacts")) {
execution.getArtifacts().remove(artifact.path("name").asText());
}
String projectName = action.path("configuration").path("ProjectName").asText(null);
ProjectArtifacts artifactsOverride = new ProjectArtifacts();
artifactsOverride.setType("CODEPIPELINE");
Build build = codeBuildService.startBuild(execution.getRegion(), execution.getAccountId(), projectName,
null, null, null, null, null, null, null);
null, null, artifactsOverride, null, null, null, null);
state.setExternalExecutionId(build.getId());
while (!Boolean.TRUE.equals(build.getBuildComplete())) {
if (abandonExternalActionIfRequested(execution, state)) {
@@ -1160,16 +1260,38 @@ public class CodePipelineService {
}
throw new AwsException("ActionExecutionFailed", message, 400);
}
if (!action.path("outputArtifacts").isEmpty()) {
byte[] output = codeBuildService.getBuildOutputArtifact(
execution.getRegion(), execution.getAccountId(), build.getId());
if (output == null) {
throw new AwsException("ActionExecutionFailed",
"CodeBuild build " + build.getId() + " did not produce an output artifact", 400);
}
for (JsonNode artifact : action.path("outputArtifacts")) {
execution.getArtifacts().put(artifact.path("name").asText(), output);
}
}
}
private void executeCodeDeploy(CodePipelineExecution execution, JsonNode action,
private void executeCodeDeploy(CodePipelinePipeline pipeline, CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException {
JsonNode config = action.path("configuration");
Map<String, Object> revision = null;
JsonNode input = action.path("inputArtifacts").path(0);
if (!input.isMissingNode()) {
revision = Map.of("revisionType", "S3",
"s3Location", Map.of("bucket", artifactBucket(action), "key", input.path("name").asText()));
String artifactName = input.path("name").asText();
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available", 400);
}
String bucket = pipeline.getDeclaration().path("artifactStore").path("location").asText(null);
if (bucket == null || bucket.isBlank()) {
throw new AwsException("InvalidActionDeclarationException", "Pipeline artifact store is required", 400);
}
String key = execution.getPipelineExecutionId() + "/" + state.getStageName() + "/"
+ state.getActionName() + "/" + artifactName + ".zip";
s3Service.putObject(bucket, key, artifact, "application/zip", Map.of());
revision = Map.of("revisionType", "S3", "s3Location", Map.of("bucket", bucket, "key", key));
}
String deploymentId = codeDeployService.createDeployment(
execution.getRegion(), config.path("ApplicationName").asText(),
@@ -1195,6 +1317,417 @@ public class CodePipelineService {
}
}
private void executeCodeDeployToEcs(CodePipelineExecution execution, JsonNode action,
ActionExecution state) throws InterruptedException {
if (ecsService == null) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS is not available", 400);
}
JsonNode config = action.path("configuration");
String applicationName = requiredCodeDeployToEcsConfiguration(config, "ApplicationName");
String deploymentGroupName = requiredCodeDeployToEcsConfiguration(config, "DeploymentGroupName");
String taskDefinitionTemplate = readCodeDeployToEcsTemplate(execution, action, config,
"TaskDefinitionTemplateArtifact", "TaskDefinitionTemplatePath");
String appSpecTemplate = readCodeDeployToEcsTemplate(execution, action, config,
"AppSpecTemplateArtifact", "AppSpecTemplatePath");
RegisterTaskDefinitionRequest taskDefinitionRequest;
try {
taskDefinitionRequest = mapper.readValue(taskDefinitionTemplate, RegisterTaskDefinitionRequest.class);
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"TaskDefinitionTemplatePath must reference a valid ECS task definition JSON template", 400);
}
if (taskDefinitionRequest.getFamily() == null || taskDefinitionRequest.getFamily().isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"Task definition template must specify family", 400);
}
substituteCodeDeployToEcsImage(execution, action, config, taskDefinitionRequest);
TaskDefinition taskDefinition = ecsService.registerTaskDefinition(taskDefinitionRequest, execution.getRegion());
String appSpec = appSpecTemplate.replace("<TASK_DEFINITION>", taskDefinition.getTaskDefinitionArn());
Map<String, Object> revision = Map.of("revisionType", "AppSpecContent",
"appSpecContent", Map.of("content", appSpec));
String deploymentId = codeDeployService.createDeployment(execution.getRegion(), applicationName,
deploymentGroupName, null, revision, "CodePipeline execution");
state.setExternalExecutionId(deploymentId);
waitForCodeDeployDeployment(execution, state, deploymentId);
}
private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, JsonNode config,
String artifactField, String pathField) {
String templatePath = requiredCodeDeployToEcsConfiguration(config, pathField);
int separator = templatePath.indexOf("::");
if (separator >= 0) {
if (separator == 0 || separator == templatePath.length() - 2
|| templatePath.indexOf("::", separator + 2) >= 0) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS template paths must use ArtifactName::TemplateFilePath", 400);
}
return readCodeDeployToEcsTemplate(execution, action, templatePath.substring(0, separator),
templatePath.substring(separator + 2));
}
String artifactName = requiredCodeDeployToEcsConfiguration(config, artifactField);
return readCodeDeployToEcsTemplate(execution, action, artifactName, templatePath);
}
private String readCodeDeployToEcsTemplate(CodePipelineExecution execution, JsonNode action, String artifactName,
String templatePath) {
boolean declaredInput = false;
for (JsonNode input : action.path("inputArtifacts")) {
if (artifactName.equals(input.path("name").asText())) {
declaredInput = true;
break;
}
}
if (!declaredInput) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS template references an unavailable input artifact: " + artifactName, 400);
}
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available: " + artifactName, 400);
}
return readTemplateFromArtifact(artifact, templatePath);
}
private static String requiredCodeDeployToEcsConfiguration(JsonNode config, String field) {
String value = config.path(field).asText(null);
if (value == null || value.isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"CodeDeployToECS " + field + " is required", 400);
}
return value;
}
private void substituteCodeDeployToEcsImage(CodePipelineExecution execution, JsonNode action, JsonNode config,
RegisterTaskDefinitionRequest taskDefinitionRequest) {
String imageArtifactName = requiredCodeDeployToEcsConfiguration(config, "Image1ArtifactName");
String containerName = requiredCodeDeployToEcsConfiguration(config, "Image1ContainerName");
String imageDetail = readCodeDeployToEcsTemplate(execution, action, imageArtifactName, "imageDetail.json");
JsonNode imageDetailJson;
try {
imageDetailJson = mapper.readTree(imageDetail);
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"imageDetail.json must contain a valid image detail JSON object", 400);
}
String imageUri = imageDetailJson.path("ImageURI").asText(null);
if (imageUri == null || imageUri.isBlank()) {
throw new AwsException("InvalidActionDeclarationException", "imageDetail.json must specify ImageURI", 400);
}
if (taskDefinitionRequest.getContainerDefinitions() == null) {
throw new AwsException("InvalidActionDeclarationException",
"Task definition template must specify containerDefinitions", 400);
}
for (ContainerDefinition container : taskDefinitionRequest.getContainerDefinitions()) {
if (containerName.equals(container.getName())) {
if (container.getImage() == null) {
throw new AwsException("InvalidActionDeclarationException",
"Image1ContainerName must identify a container with an image", 400);
}
container.setImage(container.getImage().replace("<IMAGE1_NAME>", imageUri));
return;
}
}
throw new AwsException("InvalidActionDeclarationException",
"Image1ContainerName does not match a task definition container", 400);
}
private void waitForCodeDeployDeployment(CodePipelineExecution execution, ActionExecution state, String deploymentId)
throws InterruptedException {
Deployment deployment = codeDeployService.getDeployment(execution.getRegion(), deploymentId);
while (!List.of("Succeeded", "Failed", "Stopped").contains(deployment.getStatus())) {
if (abandonExternalActionIfRequested(execution, state)) {
return;
}
TimeUnit.MILLISECONDS.sleep(POLL_INTERVAL_MS);
deployment = codeDeployService.getDeployment(execution.getRegion(), deploymentId);
}
if (abandonExternalActionIfRequested(execution, state)) {
return;
}
if (!"Succeeded".equals(deployment.getStatus())) {
String message = "CodeDeploy deployment finished with " + deployment.getStatus();
if (recordExternalActionFailureWhileStopping(execution, state, message)) {
return;
}
throw new AwsException("ActionExecutionFailed", message, 400);
}
}
private void executeCloudFormation(CodePipelinePipeline pipeline, CodePipelineExecution execution,
JsonNode action, ActionExecution state) throws InterruptedException {
JsonNode config = action.path("configuration");
String stackName = requiredActionConfiguration(config, "StackName");
String actionMode = requiredActionConfiguration(config, "ActionMode");
if (!"CREATE_UPDATE".equals(actionMode)) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ActionMode must be CREATE_UPDATE", 400);
}
String templatePath = requiredActionConfiguration(config, "TemplatePath");
int separator = templatePath.indexOf("::");
if (separator <= 0 || separator == templatePath.length() - 2
|| templatePath.indexOf("::", separator + 2) >= 0) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation TemplatePath must use ArtifactName::TemplateFilePath", 400);
}
String artifactName = templatePath.substring(0, separator);
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException", "Input artifact is not available: " + artifactName, 400);
}
String bucket = pipeline.getDeclaration().path("artifactStore").path("location").asText(null);
if (bucket == null || bucket.isBlank()) {
throw new AwsException("InvalidActionDeclarationException", "Pipeline artifact store is required", 400);
}
Map<String, ArtifactLocation> artifactLocations = materializeCloudFormationArtifacts(
execution, action, state, bucket);
String templateBody = readTemplateFromArtifact(artifact, templatePath.substring(separator + 2));
boolean exists;
try {
cloudFormationService.describeStacks(stackName, execution.getRegion(), execution.getAccountId());
exists = true;
} catch (AwsException exception) {
if (!"ValidationError".equals(exception.getErrorCode())) {
throw exception;
}
exists = false;
}
String changeSetName = "codepipeline-" + UUID.randomUUID().toString().substring(0, 8);
String changeSetType = exists ? "UPDATE" : "CREATE";
cloudFormationService.createChangeSet(stackName, changeSetName, changeSetType, templateBody, null,
resolveParameterOverrides(config, execution, action, artifactLocations), List.of(), Map.of(),
execution.getRegion(), execution.getAccountId());
Future<?> deployment = cloudFormationService.executeChangeSet(
stackName, changeSetName, execution.getRegion(), execution.getAccountId());
try {
deployment.get();
} catch (ExecutionException exception) {
Throwable cause = exception.getCause();
if (cause instanceof AwsException awsException) {
throw awsException;
}
throw new AwsException("ActionExecutionFailed", "CloudFormation deployment failed: "
+ Objects.toString(cause.getMessage(), cause.getClass().getSimpleName()), 400);
}
Stack stack = cloudFormationService.describeStacks(stackName, execution.getRegion(), execution.getAccountId()).get(0);
if (!List.of("CREATE_COMPLETE", "UPDATE_COMPLETE").contains(stack.getStatus())) {
throw new AwsException("ActionExecutionFailed", "CloudFormation stack " + stackName
+ " finished with " + stack.getStatus(), 400);
}
state.setExternalExecutionId(stack.getStackId());
}
private static String requiredActionConfiguration(JsonNode config, String field) {
String value = config.path(field).asText(null);
if (value == null || value.isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation " + field + " is required", 400);
}
return value;
}
private Map<String, ArtifactLocation> materializeCloudFormationArtifacts(CodePipelineExecution execution,
JsonNode action,
ActionExecution state,
String bucket) {
Map<String, ArtifactLocation> locations = new LinkedHashMap<>();
for (JsonNode input : action.path("inputArtifacts")) {
String artifactName = input.path("name").asText();
byte[] artifact = execution.getArtifacts().get(artifactName);
if (artifact == null) {
throw new AwsException("InvalidJobStateException",
"Input artifact is not available: " + artifactName, 400);
}
String prefix = execution.getPipelineExecutionId() + "/" + state.getStageName() + "/"
+ state.getActionName() + "/" + artifactName;
String archiveKey = prefix + ".zip";
s3Service.putObject(bucket, archiveKey, artifact, "application/zip", Map.of());
List<ArtifactLocation> nestedArtifacts = extractArtifactEntries(bucket, prefix, artifact);
ArtifactLocation location = nestedArtifacts.size() == 1 && nestedArtifacts.getFirst().key().endsWith(".zip")
? nestedArtifacts.getFirst() : new ArtifactLocation(bucket, archiveKey);
locations.put(artifactName, location);
}
return locations;
}
private List<ArtifactLocation> extractArtifactEntries(String bucket, String prefix, byte[] artifact) {
List<ArtifactLocation> nestedArtifacts = new ArrayList<>();
int extractedSize = 0;
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(artifact))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
String entryName = entry.getName();
validateArtifactEntryName(entryName);
byte[] data = zip.readNBytes(MAX_ARTIFACT_ENTRY_SIZE + 1);
if (data.length > MAX_ARTIFACT_ENTRY_SIZE) {
throw new AwsException("InvalidActionDeclarationException",
"Artifact entry exceeds 256 MB: " + entryName, 400);
}
if (data.length > MAX_EXTRACTED_ARTIFACT_SIZE - extractedSize) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation input artifact expands beyond 256 MB", 400);
}
extractedSize += data.length;
String key = prefix + "/" + entryName;
s3Service.putObject(bucket, key, data, "application/octet-stream", Map.of());
nestedArtifacts.add(new ArtifactLocation(bucket, key));
}
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Could not read CloudFormation input artifact", 400);
}
return nestedArtifacts;
}
private static void validateArtifactEntryName(String entryName) {
if (entryName.startsWith("/") || entryName.startsWith("\\") || entryName.contains("\\")) {
throw new AwsException("InvalidActionDeclarationException", "Artifact entry path is not safe", 400);
}
for (String segment : entryName.split("/")) {
if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) {
throw new AwsException("InvalidActionDeclarationException", "Artifact entry path is not safe", 400);
}
}
}
private Map<String, String> resolveParameterOverrides(JsonNode config, CodePipelineExecution execution,
JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
String overrides = config.path("ParameterOverrides").asText(null);
if (overrides == null || overrides.isBlank()) {
return Map.of();
}
try {
JsonNode values = mapper.readTree(overrides);
if (!values.isObject()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ParameterOverrides must be a JSON object", 400);
}
Map<String, String> resolved = new LinkedHashMap<>();
values.fields().forEachRemaining(entry -> resolved.put(entry.getKey(),
resolveParameterOverride(entry.getValue(), execution, action, artifactLocations)));
return resolved;
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation ParameterOverrides must be valid JSON", 400);
}
}
private String resolveParameterOverride(JsonNode value, CodePipelineExecution execution, JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
if (value.isTextual() || value.isNumber() || value.isBoolean()) {
return value.asText();
}
if (!value.isObject()) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation parameter override must be a scalar or supported function", 400);
}
JsonNode artifactAtt = value.path("Fn::GetArtifactAtt");
if (!artifactAtt.isMissingNode()) {
List<String> arguments = functionArguments(artifactAtt, 2, "Fn::GetArtifactAtt");
ArtifactLocation location = requireArtifactLocation(arguments.getFirst(), action, artifactLocations);
return switch (arguments.get(1)) {
case "BucketName" -> location.bucket();
case "ObjectKey" -> location.key();
case "URL" -> "https://s3." + execution.getRegion() + ".amazonaws.com/"
+ location.bucket() + "/" + location.key();
default -> throw new AwsException("InvalidActionDeclarationException",
"Fn::GetArtifactAtt attribute is not supported: " + arguments.get(1), 400);
};
}
JsonNode getParam = value.path("Fn::GetParam");
if (!getParam.isMissingNode()) {
List<String> arguments = functionArguments(getParam, 3, "Fn::GetParam");
requireArtifactLocation(arguments.getFirst(), action, artifactLocations);
byte[] artifact = execution.getArtifacts().get(arguments.getFirst());
String contents = readTemplateFromArtifact(artifact, arguments.get(1));
try {
JsonNode parameters = mapper.readTree(contents);
JsonNode parameter = parameters.path(arguments.get(2));
if (!parameters.isObject() || parameter.isMissingNode() || !parameter.isValueNode()) {
throw new AwsException("InvalidActionDeclarationException",
"Fn::GetParam could not resolve " + arguments.get(2), 400);
}
return parameter.asText();
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Fn::GetParam file must contain a JSON object", 400);
}
}
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation parameter override uses an unsupported function", 400);
}
private static List<String> functionArguments(JsonNode arguments, int expectedCount, String functionName) {
if (!arguments.isArray() || arguments.size() != expectedCount) {
throw new AwsException("InvalidActionDeclarationException",
functionName + " requires " + expectedCount + " string arguments", 400);
}
List<String> values = new ArrayList<>();
for (JsonNode argument : arguments) {
if (!argument.isTextual() || argument.asText().isBlank()) {
throw new AwsException("InvalidActionDeclarationException",
functionName + " requires " + expectedCount + " string arguments", 400);
}
values.add(argument.asText());
}
return values;
}
private static ArtifactLocation requireArtifactLocation(String artifactName, JsonNode action,
Map<String, ArtifactLocation> artifactLocations) {
boolean isInputArtifact = false;
for (JsonNode input : action.path("inputArtifacts")) {
if (artifactName.equals(input.path("name").asText())) {
isInputArtifact = true;
break;
}
}
ArtifactLocation location = artifactLocations.get(artifactName);
if (!isInputArtifact || location == null) {
throw new AwsException("InvalidActionDeclarationException",
"Parameter override references an unavailable input artifact: " + artifactName, 400);
}
return location;
}
private static String readTemplateFromArtifact(byte[] artifact, String templatePath) {
if (templatePath.startsWith("/") || templatePath.startsWith("\\") || templatePath.contains("\\")) {
throw new AwsException("InvalidActionDeclarationException", "TemplatePath is not safe", 400);
}
for (String segment : templatePath.split("/")) {
if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) {
throw new AwsException("InvalidActionDeclarationException", "TemplatePath is not safe", 400);
}
}
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(artifact))) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory() && templatePath.equals(entry.getName())) {
byte[] template = zip.readNBytes(51_201);
if (template.length > 51_200) {
throw new AwsException("InvalidActionDeclarationException",
"CloudFormation template exceeds 51200 bytes", 400);
}
return new String(template, StandardCharsets.UTF_8);
}
}
} catch (java.io.IOException exception) {
throw new AwsException("InvalidActionDeclarationException",
"Could not read CloudFormation template from input artifact", 400);
}
throw new AwsException("InvalidActionDeclarationException",
"TemplatePath does not exist in the input artifact", 400);
}
private record ArtifactLocation(String bucket, String key) {
}
private boolean abandonExternalActionIfRequested(CodePipelineExecution execution, ActionExecution state) {
if (!execution.isStopRequested() || !execution.isAbandon()) {
return false;
@@ -1518,9 +2051,9 @@ public class CodePipelineService {
private ObjectNode executionNode(CodePipelineExecution execution) {
ObjectNode node = mapper.valueToTree(execution);
node.remove(List.of("accountId", "region", "startTime", "lastUpdateTime",
node.remove(List.of("accountId", "region", "startTime", "lastUpdateTime", "artifacts",
"sourceRevisions", "actionExecutions", "currentStage", "stopRequested", "abandon",
"rollbackTargetPipelineExecutionId"));
"rollbackTargetPipelineExecutionId", "retryStageName", "retryActionNames"));
if (execution.getRollbackTargetPipelineExecutionId() != null) {
node.putObject("rollbackMetadata").put(
"rollbackTargetPipelineExecutionId", execution.getRollbackTargetPipelineExecutionId());
@@ -1728,19 +2261,10 @@ public class CodePipelineService {
return account + ":" + region + ":" + pipelineName;
}
private static String artifactKey(CodePipelineExecution execution, String artifactName) {
return execution.getAccountId() + ":" + execution.getPipelineExecutionId() + ":" + artifactName;
}
private static String artifactBucket(JsonNode action) {
return action.path("configuration").path("BucketName").asText("codepipeline-artifacts");
}
private void clearRuntimeArtifacts(CodePipelineExecution execution) {
String prefix = execution.getAccountId() + ":" + execution.getPipelineExecutionId() + ":";
runtimeArtifacts.keySet().removeIf(key -> key.startsWith(prefix));
}
private static boolean isTerminal(String status) {
return List.of("Succeeded", "Failed", "Stopped", "Superseded", "Cancelled").contains(status);
}
@@ -26,6 +26,7 @@ public class CodePipelineExecution {
private Double startTime;
private Double lastUpdateTime;
private List<Map<String, Object>> artifactRevisions = new ArrayList<>();
private Map<String, byte[]> artifacts = new LinkedHashMap<>();
private List<Map<String, Object>> sourceRevisions = new ArrayList<>();
private List<Map<String, String>> variables = new ArrayList<>();
private Map<String, String> trigger = new LinkedHashMap<>();
@@ -35,6 +36,8 @@ public class CodePipelineExecution {
private volatile boolean stopRequested;
private volatile boolean abandon;
private String rollbackTargetPipelineExecutionId;
private String retryStageName;
private List<String> retryActionNames = new ArrayList<>();
public String getAccountId() {
return accountId;
@@ -132,6 +135,14 @@ public class CodePipelineExecution {
this.artifactRevisions = artifactRevisions;
}
public Map<String, byte[]> getArtifacts() {
return artifacts;
}
public void setArtifacts(Map<String, byte[]> artifacts) {
this.artifacts = artifacts == null ? new LinkedHashMap<>() : artifacts;
}
public List<Map<String, Object>> getSourceRevisions() {
return sourceRevisions;
}
@@ -205,6 +216,22 @@ public class CodePipelineExecution {
this.rollbackTargetPipelineExecutionId = rollbackTargetPipelineExecutionId;
}
public String getRetryStageName() {
return retryStageName;
}
public void setRetryStageName(String retryStageName) {
this.retryStageName = retryStageName;
}
public List<String> getRetryActionNames() {
return retryActionNames;
}
public void setRetryActionNames(List<String> retryActionNames) {
this.retryActionNames = retryActionNames == null ? new ArrayList<>() : retryActionNames;
}
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ActionExecution {
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
"UserMigration_Authentication");
if (user == null) throw ae;
if (user == null) {
if (!CognitoService.hidesUserExistence(client)) throw ae;
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
}
}
firePreAuthentication(pool, client, user, null, clientMetadata, false);
@@ -1363,6 +1366,62 @@ final class CognitoAuthFlowHandler {
return service.generateAuthResult(user, pool, client, override);
}
/**
* Checks a password typed into the hosted login page. Same rules and triggers as
* USER_PASSWORD_AUTH, minus SECRET_HASH: the page is Cognito's own, not the app client.
* Throws {@code NewPasswordRequiredException} when the user still has a temporary password.
*/
CognitoUser authenticateHostedLogin(UserPool pool, UserPoolClient client, String username, String password) {
CognitoUser user;
try {
user = service.adminGetUser(pool.getId(), username);
} catch (AwsException ae) {
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
user = tryUserMigration(pool, client, username, password, null, Map.of(),
"UserMigration_Authentication");
if (user == null) throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
}
firePreAuthentication(pool, client, user, null, Map.of(), false);
if (!user.isEnabled()) throw new AwsException("NotAuthorizedException", "User is disabled", 400);
if ("RESET_REQUIRED".equals(user.getUserStatus())) {
throw new AwsException("PasswordResetRequiredException", "Password reset required", 400);
}
if (user.getPasswordHash() == null || !user.getPasswordHash().equals(service.hashPassword(password))) {
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
}
if ("UNCONFIRMED".equals(user.getUserStatus())) {
throw new AwsException("UserNotConfirmedException", "User is not confirmed", 400);
}
if (user.isTemporaryPassword() || "FORCE_CHANGE_PASSWORD".equals(user.getUserStatus())) {
throw new AwsException("NewPasswordRequiredException", "Password change required", 400);
}
firePostAuthentication(pool, client, user, Map.of(), false);
return user;
}
/** Tokens for an authorization code from the hosted login or a federated sign-in. */
Map<String, Object> issueHostedTokens(UserPool pool, UserPoolClient client, CognitoUser user, String nonce) {
CognitoService.ClaimsOverride override = withIdClaim(
firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_HostedAuth"), "nonce", nonce);
return service.generateAuthResult(user, pool, client, override);
}
private static CognitoService.ClaimsOverride withIdClaim(CognitoService.ClaimsOverride override,
String name, String value) {
if (value == null || value.isBlank()) return override;
CognitoService.ClaimsOverride base = override != null ? override
: new CognitoService.ClaimsOverride(null, null, null, null, null, null, null, null, null);
Map<String, Object> idClaims = new HashMap<>();
if (base.idClaimsToAddOrOverride() != null) idClaims.putAll(base.idClaimsToAddOrOverride());
idClaims.put(name, value);
return new CognitoService.ClaimsOverride(idClaims, base.idClaimsToSuppress(),
base.accessClaimsToAddOrOverride(), base.accessClaimsToSuppress(),
base.scopesToAdd(), base.scopesToSuppress(),
base.groupsToOverride(), base.iamRolesToOverride(), base.preferredRole());
}
CognitoService.ClaimsOverride preTokenGenerationForRefresh(UserPool pool, UserPoolClient client, CognitoUser user) {
return firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_RefreshTokens");
}
@@ -14,12 +14,13 @@ import org.jboss.logging.Logger;
import java.net.URI;
import java.util.Optional;
import java.util.Set;
/**
* Routes Cognito custom-domain requests by Host. On AWS a custom domain serves
* {@code https://<domain>/oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize}, and
* {@code /oauth2/idpresponse}; Floci maps those onto
* the {@code /cognito-idp/oauth2/...} handlers and pins the pool and the account that own the
* {@code https://<domain>/oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize},
* {@code /oauth2/idpresponse} and the hosted {@code /login} and {@code /logout} pages; Floci maps
* those onto the {@code /cognito-idp/...} handlers and pins the pool and the account that own the
* domain, since the request itself carries no AWS credential.
*/
@Provider
@@ -37,6 +38,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
private static final Logger LOG = Logger.getLogger(CognitoCustomDomainFilter.class);
private static final String OAUTH_PREFIX = "/oauth2/";
private static final String TARGET_PREFIX = "/cognito-idp/oauth2/";
private static final Set<String> HOSTED_PAGES = Set.of("/login", "/logout");
private static final String HOSTED_TARGET_PREFIX = "/cognito-idp/";
private final CognitoService cognitoService;
@@ -49,7 +52,11 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
public void filter(ContainerRequestContext requestContext) {
URI originalUri = requestContext.getUriInfo().getRequestUri();
String path = originalUri.getRawPath();
if (path == null || !path.startsWith(OAUTH_PREFIX)) {
if (path == null) {
return;
}
boolean hostedPage = HOSTED_PAGES.contains(path);
if (!path.startsWith(OAUTH_PREFIX) && !hostedPage) {
return;
}
String host = RequestHost.of(requestContext);
@@ -62,7 +69,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
}
URI newUri = UriBuilder.fromUri(originalUri)
.replacePath(TARGET_PREFIX + path.substring(OAUTH_PREFIX.length()))
.replacePath(hostedPage ? HOSTED_TARGET_PREFIX + path.substring(1)
: TARGET_PREFIX + path.substring(OAUTH_PREFIX.length()))
.build();
LOG.debugv("Cognito custom domain routing: {0}{1} -> {2}", host, path, newUri.getPath());
requestContext.setProperty(POOL_PROPERTY, domain.get().getUserPoolId());
@@ -47,6 +47,12 @@ public class CognitoFederationService {
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> scopes,
String nonce, String providerName, String relyingPartyState) {
return beginAuthorization(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, null);
}
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> scopes,
String nonce, String providerName, String relyingPartyState,
String codeChallenge) {
IdentityProvider provider = cognitoService.describeIdentityProvider(userPoolId, providerName);
requireOidcProvider(provider);
String authorizeEndpoint = requiredProviderDetail(provider, "authorize_url", "authorize endpoint");
@@ -54,7 +60,8 @@ public class CognitoFederationService {
validateAuthorizeEndpoint(authorizeEndpoint, provider.getProviderName());
Instant expiresAt = clock.instant().plus(TRANSACTION_LIFETIME);
CognitoAuthorizationTransaction transaction = new CognitoAuthorizationTransaction(
userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt);
userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt,
codeChallenge);
String state = stateStore.putTransaction(transaction);
Map<String, String> parameters = new LinkedHashMap<>();
@@ -89,7 +96,8 @@ public class CognitoFederationService {
subject, issuer, mapAttributes(provider, claims));
CognitoAuthorizationCode authorizationCode = new CognitoAuthorizationCode(
transaction.userPoolId(), transaction.clientId(), user.getUsername(), transaction.redirectUri(),
transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME));
transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME), transaction.nonce(),
transaction.codeChallenge());
return stateStore.putAuthorizationCode(authorizationCode);
}
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cognito;
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession;
import jakarta.enterprise.context.ApplicationScoped;
import java.security.SecureRandom;
@@ -20,6 +21,7 @@ public class CognitoFederationStateStore {
private final ConcurrentHashMap<String, CognitoAuthorizationTransaction> transactions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, CognitoAuthorizationCode> authorizationCodes = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, CognitoHostedSession> hostedSessions = new ConcurrentHashMap<>();
private final Clock clock;
private final SecureRandom secureRandom = new SecureRandom();
@@ -55,6 +57,31 @@ public class CognitoFederationStateStore {
return Optional.of(authorizationCode);
}
public String putHostedSession(CognitoHostedSession session) {
return put(hostedSessions, session);
}
public Optional<CognitoHostedSession> findHostedSession(String id) {
if (id == null) {
return Optional.empty();
}
CognitoHostedSession session = hostedSessions.get(id);
if (session == null) {
return Optional.empty();
}
if (isExpired(session.expiresAt())) {
hostedSessions.remove(id, session);
return Optional.empty();
}
return Optional.of(session);
}
public void removeHostedSession(String id) {
if (id != null) {
hostedSessions.remove(id);
}
}
private <T> String put(ConcurrentHashMap<String, T> store, T value) {
String key;
do {
@@ -0,0 +1,502 @@
package io.github.hectorvent.floci.services.cognito;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession;
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
import io.github.hectorvent.floci.services.cognito.model.UserPool;
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.CookieParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Cognito's hosted (managed) login pages: {@code /login} and {@code /logout}, reached from
* {@code /oauth2/authorize} when no external identity provider is named. A successful sign-in
* starts a browser session and redirects to the client's callback URL with an authorization
* code, which {@code /oauth2/token} redeems.
*/
@ApplicationScoped
@Path("/")
public class CognitoHostedLoginController {
static final String SESSION_COOKIE = "cognito";
static final String CSRF_COOKIE = "XSRF-TOKEN";
private static final Duration SESSION_LIFETIME = Duration.ofHours(1);
private static final Duration AUTHORIZATION_CODE_LIFETIME = Duration.ofMinutes(5);
private static final String COGNITO_PROVIDER = "COGNITO";
private static final String NO_PROVIDERS = "This app client has no identity providers to sign in with.";
private final CognitoService cognitoService;
private final CognitoFederationStateStore stateStore;
private final Clock clock;
private final SecureRandom secureRandom = new SecureRandom();
@Inject
public CognitoHostedLoginController(CognitoService cognitoService, CognitoFederationStateStore stateStore,
Clock clock) {
this.cognitoService = cognitoService;
this.stateStore = stateStore;
this.clock = clock;
}
/** The parameters of an authorize request, carried through the login form unchanged. */
record AuthorizeRequest(String clientId, String redirectUri, String scope, String state, String nonce,
String codeChallenge, String codeChallengeMethod) {
Map<String, String> asQuery() {
Map<String, String> query = new LinkedHashMap<>();
query.put("client_id", clientId);
query.put("redirect_uri", redirectUri);
query.put("response_type", "code");
putIfPresent(query, "scope", scope);
putIfPresent(query, "state", state);
putIfPresent(query, "nonce", nonce);
putIfPresent(query, "code_challenge", codeChallenge);
putIfPresent(query, "code_challenge_method", codeChallengeMethod);
return query;
}
}
/**
* Continues an authorize request that names no external provider: an existing session gets a
* code straight away, anyone else is sent to the login page, which offers the pool's own
* password form and a button for each of the client's other identity providers.
*/
Response authorizeWithCognito(ContainerRequestContext requestContext, UserPoolClient client,
AuthorizeRequest request, String prompt, String sessionId) {
if (!offersCognito(client) && externalProviders(client).isEmpty()) {
return errorPage(NO_PROVIDERS);
}
if (offersCognito(client) && !"login".equals(prompt)) {
Optional<CognitoUser> signedIn = sessionUser(client, sessionId);
if (signedIn.isPresent()) {
return redirectWithCode(client, request, signedIn.get(), null);
}
}
return Response.status(Response.Status.FOUND)
.location(URI.create(pagePath(requestContext, "login") + "?" + query(request.asQuery())))
.build();
}
@GET
@Path("/cognito-idp/login")
public Response loginPage(@Context ContainerRequestContext requestContext,
@QueryParam("client_id") String clientId,
@QueryParam("redirect_uri") String redirectUri,
@QueryParam("response_type") String responseType,
@QueryParam("scope") String scope,
@QueryParam("state") String state,
@QueryParam("nonce") String nonce,
@QueryParam("code_challenge") String codeChallenge,
@QueryParam("code_challenge_method") String codeChallengeMethod) {
AuthorizeRequest request = new AuthorizeRequest(trimToNull(clientId), trimToNull(redirectUri),
trimToNull(scope), state, trimToNull(nonce), trimToNull(codeChallenge), trimToNull(codeChallengeMethod));
Object validated = validate(requestContext, request, responseType);
if (validated instanceof Response error) {
return error;
}
String csrf = randomToken();
return loginForm(requestContext, (UserPoolClient) validated, request, csrf, null, null, 200)
.cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1))
.build();
}
@POST
@Path("/cognito-idp/login")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response submitLogin(@Context ContainerRequestContext requestContext,
@CookieParam(CSRF_COOKIE) String csrfCookie,
MultivaluedMap<String, String> form) {
AuthorizeRequest request = new AuthorizeRequest(trimToNull(form.getFirst("client_id")),
trimToNull(form.getFirst("redirect_uri")), trimToNull(form.getFirst("scope")),
form.getFirst("state"), trimToNull(form.getFirst("nonce")),
trimToNull(form.getFirst("code_challenge")), trimToNull(form.getFirst("code_challenge_method")));
Object validated = validate(requestContext, request, form.getFirst("response_type"));
if (validated instanceof Response error) {
return error;
}
UserPoolClient client = (UserPoolClient) validated;
if (!offersCognito(client)) {
return errorPage("This app client does not offer sign-in with a user name and password "
+ "(COGNITO is not one of its identity providers).");
}
String csrf = form.getFirst("_csrf");
if (csrf == null || csrfCookie == null || !constantTimeEquals(csrf, csrfCookie)) {
return errorPage("Your sign-in form expired. Go back to the app and sign in again.");
}
String username = trimToNull(form.getFirst("username"));
String password = form.getFirst("password");
if (username == null || password == null || password.isEmpty()) {
return loginForm(requestContext, client, request, csrf, username, "Enter your user name and password.", 400).build();
}
CognitoUser user;
try {
user = cognitoService.authenticateHostedLogin(client, username, password);
} catch (AwsException e) {
return loginForm(requestContext, client, request, csrf, username, describe(e), 400).build();
}
String sessionId = stateStore.putHostedSession(new CognitoHostedSession(
client.getUserPoolId(), user.getUsername(), clock.instant().plus(SESSION_LIFETIME)));
return redirectWithCode(client, request, user,
cookie(requestContext, SESSION_COOKIE, sessionId, (int) SESSION_LIFETIME.toSeconds()));
}
@GET
@Path("/cognito-idp/logout")
public Response logout(@Context ContainerRequestContext requestContext,
@CookieParam(SESSION_COOKIE) String sessionId,
@QueryParam("client_id") String clientId,
@QueryParam("logout_uri") String logoutUri,
@QueryParam("redirect_uri") String redirectUri,
@QueryParam("response_type") String responseType,
@QueryParam("scope") String scope,
@QueryParam("state") String state) {
UserPoolClient client = findClient(requestContext, trimToNull(clientId));
if (client == null) {
return errorPage("The app client was not found.");
}
String location;
if (trimToNull(logoutUri) != null) {
if (!client.getLogoutURLs().contains(logoutUri.trim())) {
return errorPage("This sign-out URL is not registered for the app client.");
}
location = logoutUri.trim();
} else {
AuthorizeRequest request = new AuthorizeRequest(client.getClientId(), trimToNull(redirectUri),
trimToNull(scope), state, null, null, null);
Object validated = validate(requestContext, request, responseType);
if (validated instanceof Response error) {
return error;
}
location = pagePath(requestContext, "login") + "?" + query(request.asQuery());
}
stateStore.removeHostedSession(sessionId);
return Response.status(Response.Status.FOUND)
.location(URI.create(location))
.cookie(cookie(requestContext, SESSION_COOKIE, "", 0))
.build();
}
private Optional<CognitoUser> sessionUser(UserPoolClient client, String sessionId) {
Optional<CognitoHostedSession> session = stateStore.findHostedSession(sessionId)
.filter(s -> s.userPoolId().equals(client.getUserPoolId()));
if (session.isEmpty()) {
return Optional.empty();
}
try {
CognitoUser user = cognitoService.adminGetUser(client.getUserPoolId(), session.get().username());
return user.isEnabled() ? Optional.of(user) : Optional.empty();
} catch (AwsException e) {
return Optional.empty();
}
}
private Response redirectWithCode(UserPoolClient client, AuthorizeRequest request, CognitoUser user,
NewCookie sessionCookie) {
String code = stateStore.putAuthorizationCode(new CognitoAuthorizationCode(
client.getUserPoolId(), client.getClientId(), user.getUsername(), request.redirectUri(),
splitScopes(request.scope()), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME),
request.nonce(), request.codeChallenge()));
Map<String, String> parameters = new LinkedHashMap<>();
parameters.put("code", code);
putIfPresent(parameters, "state", request.state());
String redirectUri = request.redirectUri();
Response.ResponseBuilder response = Response.status(Response.Status.FOUND)
.location(URI.create(redirectUri + (redirectUri.contains("?") ? "&" : "?") + query(parameters)));
if (sessionCookie != null) {
response.cookie(sessionCookie);
}
return response.build();
}
/** Returns the client when the request is one Cognito would accept, otherwise an error page. */
private Object validate(ContainerRequestContext requestContext, AuthorizeRequest request, String responseType) {
if (!"code".equals(trimToNull(responseType))) {
return errorPage("Only response_type=code is supported.");
}
if (request.clientId() == null || request.redirectUri() == null) {
return errorPage("client_id and redirect_uri are required.");
}
UserPoolClient client = findClient(requestContext, request.clientId());
if (client == null) {
return errorPage("The app client was not found.");
}
if (!client.isAllowedOAuthFlowsUserPoolClient() || !client.getAllowedOAuthFlows().contains("code")) {
return errorPage("This app client is not allowed to use the authorization code flow.");
}
if (!client.getCallbackURLs().contains(request.redirectUri())) {
return errorPage("redirect_uri is not registered for this app client.");
}
if (!offersCognito(client) && externalProviders(client).isEmpty()) {
return errorPage(NO_PROVIDERS);
}
String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod());
if (pkceError != null) {
return errorPage(pkceError);
}
return client;
}
private static boolean offersCognito(UserPoolClient client) {
return client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER);
}
private static List<String> externalProviders(UserPoolClient client) {
return client.getSupportedIdentityProviders().stream()
.filter(provider -> !COGNITO_PROVIDER.equals(provider))
.toList();
}
static String pkceError(String codeChallenge, String codeChallengeMethod) {
if (codeChallenge == null) {
return codeChallengeMethod == null ? null : "code_challenge_method was sent without code_challenge.";
}
if (!"S256".equals(codeChallengeMethod)) {
return "code_challenge_method must be S256.";
}
return null;
}
private UserPoolClient findClient(ContainerRequestContext requestContext, String clientId) {
if (clientId == null) {
return null;
}
try {
UserPoolClient client = cognitoService.findClientById(clientId);
String domainPoolId = (String) requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY);
return domainPoolId == null || domainPoolId.equals(client.getUserPoolId()) ? client : null;
} catch (AwsException e) {
return null;
}
}
private static String describe(AwsException e) {
return switch (e.getErrorCode()) {
case "NotAuthorizedException" -> "User is disabled".equals(e.getMessage())
? "This account is disabled." : "Incorrect user name or password.";
case "UserNotConfirmedException" -> "This account is not confirmed yet. Confirm it from the app first.";
case "PasswordResetRequiredException" -> "This account needs a password reset before it can sign in.";
case "NewPasswordRequiredException" -> "This account still has a temporary password. "
+ "Set a permanent one before signing in here.";
case "UserLambdaValidationException" -> e.getMessage();
default -> "Sign-in failed: " + e.getMessage();
};
}
private Response.ResponseBuilder loginForm(ContainerRequestContext requestContext, UserPoolClient client,
AuthorizeRequest request, String csrf, String username,
String error, int status) {
UserPool pool = cognitoService.describeUserPool(client.getUserPoolId());
boolean email = pool.getUsernameAttributes() != null && pool.getUsernameAttributes().contains("email");
StringBuilder hidden = new StringBuilder();
for (Map.Entry<String, String> field : request.asQuery().entrySet()) {
hidden.append("<input type=\"hidden\" name=\"").append(escape(field.getKey()))
.append("\" value=\"").append(escape(field.getValue())).append("\">");
}
hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
String appName = client.getClientName() == null || client.getClientName().isBlank()
? pool.getName() : client.getClientName();
// Each external provider starts the federated flow with the same authorize parameters.
StringBuilder providers = new StringBuilder();
for (String provider : externalProviders(client)) {
Map<String, String> query = new LinkedHashMap<>(request.asQuery());
query.put("identity_provider", provider);
providers.append("<a class=\"provider\" href=\"").append(escape(authorizePath(requestContext) + "?" + query(query)))
.append("\">Continue with ").append(escape(provider)).append("</a>");
}
String passwordForm = !offersCognito(client) ? "" : """
<form method="post" action="">
%s
<label>%s<input name="username" type="%s" autocomplete="username" value="%s" required autofocus></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
""".formatted(hidden, email ? "Email" : "User name", email ? "email" : "text",
escape(username == null ? "" : username));
String divider = providers.isEmpty() || passwordForm.isEmpty() ? "" : "<p class=\"divider\"><span>or</span></p>";
String body = """
<h1>Sign in</h1>
<p class="muted">to continue to <strong>%s</strong></p>
%s
%s%s<div class="providers">%s</div>
""".formatted(escape(appName),
error == null ? "" : "<p class=\"error\" role=\"alert\">" + escape(error) + "</p>",
passwordForm, divider, providers);
return Response.status(status).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"))
.entity(page("Sign in", body))
.header("Cache-Control", "no-store")
.header("X-Frame-Options", "DENY")
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' "
+ cspSource(request.redirectUri()) + "; frame-ancestors 'none'");
}
private static String cspSource(String redirectUri) {
URI uri = URI.create(redirectUri);
return uri.getScheme() + "://" + uri.getRawAuthority();
}
private Response errorPage(String message) {
return Response.status(400).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"))
.entity(page("Sign-in error", "<h1>Something went wrong</h1><p class=\"error\" role=\"alert\">"
+ escape(message) + "</p>"))
.header("Cache-Control", "no-store")
.header("X-Frame-Options", "DENY")
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'")
.build();
}
private static String page(String title, String body) {
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
:root { --bg:#f4f5f7; --card:#fff; --text:#17191c; --muted:#5d6470; --border:#d9dde3; --accent:#2f5fd0; --on-accent:#fff; --error:#b42318; --error-bg:#fdeceb; }
@media (prefers-color-scheme: dark) { :root { --bg:#101215; --card:#181b20; --text:#e8eaed; --muted:#9aa1ac; --border:#2c3139; --accent:#7c9ef0; --on-accent:#0b1020; --error:#f08b82; --error-bg:#3a1714; } }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px 16px; background:var(--bg); color:var(--text); font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { width:100%%; max-width:380px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:28px; }
h1 { margin:0 0 4px; font-size:22px; }
.muted { color:var(--muted); margin:0 0 20px; overflow-wrap:anywhere; }
form { display:grid; gap:14px; }
label { display:grid; gap:6px; font-weight:600; font-size:14px; }
input { font:inherit; padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); }
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
button { font:inherit; font-weight:600; padding:10px 12px; border:0; border-radius:6px; background:var(--accent); color:var(--on-accent); cursor:pointer; }
.providers { display:grid; gap:10px; }
.provider { display:block; padding:10px 12px; border:1px solid var(--border); border-radius:6px; color:var(--text); font-weight:600; text-align:center; text-decoration:none; overflow-wrap:anywhere; }
.provider:hover { border-color:var(--accent); color:var(--accent); }
.divider { display:flex; align-items:center; gap:10px; margin:16px 0; color:var(--muted); font-size:13px; }
.divider::before, .divider::after { content:""; flex:1; border-top:1px solid var(--border); }
.error { margin:0 0 16px; padding:10px 12px; border-radius:6px; background:var(--error-bg); color:var(--error); overflow-wrap:anywhere; }
footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; }
</style>
</head>
<body><main>%s<footer>Cognito-compatible sign-in · local emulator</footer></main></body>
</html>
""".formatted(escape(title), body);
}
private NewCookie cookie(ContainerRequestContext requestContext, String name, String value, int maxAge) {
return new NewCookie.Builder(name).value(value).path("/").maxAge(maxAge)
.httpOnly(true).secure(isHttps(requestContext)).sameSite(NewCookie.SameSite.LAX).build();
}
private static boolean isHttps(ContainerRequestContext requestContext) {
String forwarded = requestContext.getHeaderString("X-Forwarded-Proto");
if (forwarded != null) {
return "https".equalsIgnoreCase(forwarded.split(",")[0].trim());
}
return "https".equalsIgnoreCase(requestContext.getUriInfo().getRequestUri().getScheme());
}
private static String authorizePath(ContainerRequestContext requestContext) {
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
return customDomain ? "/oauth2/authorize" : "/cognito-idp/oauth2/authorize";
}
/** Custom domains serve the pages at /login and /logout; everything else under /cognito-idp/. */
private static String pagePath(ContainerRequestContext requestContext, String page) {
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
return (customDomain ? "/" : "/cognito-idp/") + page;
}
private String randomToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static boolean constantTimeEquals(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
static String escape(String value) {
StringBuilder escaped = new StringBuilder(value.length());
for (char c : value.toCharArray()) {
switch (c) {
case '&' -> escaped.append("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#39;");
default -> escaped.append(c);
}
}
return escaped.toString();
}
private static String query(Map<String, String> parameters) {
List<String> pairs = new ArrayList<>();
for (Map.Entry<String, String> parameter : parameters.entrySet()) {
pairs.add(encode(parameter.getKey()) + "=" + encode(parameter.getValue()));
}
return String.join("&", pairs);
}
/** Query-string encoding with spaces as %20, which every URL parser reads back as a space. */
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
private static void putIfPresent(Map<String, String> map, String key, String value) {
if (value != null) {
map.put(key, value);
}
}
private static List<String> splitScopes(String scope) {
if (scope == null) {
return List.of();
}
List<String> scopes = new ArrayList<>();
for (String value : scope.trim().split("\\s+")) {
if (!value.isBlank()) {
scopes.add(value);
}
}
return scopes;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -6,11 +6,11 @@ import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
import io.github.hectorvent.floci.services.cognito.model.UserPool;
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.CookieParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
@@ -46,17 +46,37 @@ public class CognitoOAuthController {
private final ObjectMapper objectMapper;
private final CognitoFederationService federationService;
private final CognitoFederationStateStore stateStore;
private final CognitoHostedLoginController hostedLogin;
@Inject
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
CognitoFederationService federationService,
CognitoFederationStateStore stateStore) {
CognitoFederationStateStore stateStore,
CognitoHostedLoginController hostedLogin) {
this.cognitoService = cognitoService;
this.objectMapper = objectMapper;
this.federationService = federationService;
this.stateStore = stateStore;
this.hostedLogin = hostedLogin;
}
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
CognitoFederationService federationService,
CognitoFederationStateStore stateStore) {
this(cognitoService, objectMapper, federationService, stateStore, null);
}
public Response authorize(ContainerRequestContext requestContext, String clientId, String redirectUri,
String responseType, String scope, String nonce, String providerName,
String relyingPartyState) {
return authorize(requestContext, clientId, redirectUri, responseType, scope, nonce, providerName,
relyingPartyState, null, null, null, null);
}
/**
* Without {@code identity_provider} (or with {@code COGNITO}) the pool's own users sign in on
* the hosted login page; any other provider name starts a federated sign-in.
*/
@GET
@Path("/cognito-idp/oauth2/authorize")
public Response authorize(@Context ContainerRequestContext requestContext,
@@ -66,13 +86,22 @@ public class CognitoOAuthController {
@QueryParam("scope") String scope,
@QueryParam("nonce") String nonce,
@QueryParam("identity_provider") String providerName,
@QueryParam("state") String relyingPartyState) {
@QueryParam("state") String relyingPartyState,
@QueryParam("code_challenge") String codeChallenge,
@QueryParam("code_challenge_method") String codeChallengeMethod,
@QueryParam("prompt") String prompt,
@CookieParam(CognitoHostedLoginController.SESSION_COOKIE) String sessionId) {
if (!"code".equals(trimToNull(responseType))) {
return oauthError("unsupported_response_type", "Only response_type=code is supported");
}
if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || trimToNull(providerName) == null) {
boolean hosted = trimToNull(providerName) == null || "COGNITO".equals(providerName.trim());
if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || (hosted && hostedLogin == null)) {
return oauthError("invalid_request", "client_id, redirect_uri, and identity_provider are required");
}
String pkceError = CognitoHostedLoginController.pkceError(trimToNull(codeChallenge), trimToNull(codeChallengeMethod));
if (pkceError != null) {
return oauthError("invalid_request", pkceError);
}
UserPoolClient client;
try {
@@ -91,9 +120,20 @@ public class CognitoOAuthController {
return oauthError("invalid_request", "redirect_uri is not registered for this client");
}
if (hosted) {
return hostedLogin.authorizeWithCognito(requestContext, client,
new CognitoHostedLoginController.AuthorizeRequest(clientId.trim(), redirectUri, trimToNull(scope),
relyingPartyState, trimToNull(nonce), trimToNull(codeChallenge),
trimToNull(codeChallengeMethod)),
trimToNull(prompt), sessionId);
}
try {
String location = federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState);
String location = trimToNull(codeChallenge) == null
? federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState)
: federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState, codeChallenge.trim());
return Response.status(Response.Status.FOUND).location(URI.create(location)).build();
} catch (AwsException e) {
return oauthError("invalid_request", e.getMessage());
@@ -185,8 +225,12 @@ public class CognitoOAuthController {
if ("authorization_code".equals(grantType)) {
return redeemAuthorizationCode(clientId, clientSecret, formParams, domainPoolId);
}
if ("refresh_token".equals(grantType)) {
return refreshTokens(clientId, clientSecret, formParams, domainPoolId);
}
if (!"client_credentials".equals(grantType)) {
return oauthError("unsupported_grant_type", "Only client_credentials and authorization_code are supported");
return oauthError("unsupported_grant_type",
"Only client_credentials, authorization_code and refresh_token are supported");
}
String scope = trimToNull(formParams.getFirst("scope"));
@@ -249,10 +293,12 @@ public class CognitoOAuthController {
return oauthError("invalid_grant", "Authorization code is invalid or has expired");
}
CognitoAuthorizationCode consumedCode = consumed.get();
if (!pkceVerified(consumedCode.codeChallenge(), trimToNull(formParams.getFirst("code_verifier")))) {
return oauthError("invalid_grant", "code_verifier does not match the code_challenge");
}
try {
UserPool pool = cognitoService.describeUserPool(consumedCode.userPoolId());
CognitoUser user = cognitoService.adminGetUser(consumedCode.userPoolId(), consumedCode.userId());
Map<String, Object> authentication = cognitoService.generateAuthResult(user, pool, client, null);
Map<String, Object> authentication = cognitoService.issueHostedTokens(client, user, consumedCode.nonce());
ObjectNode body = objectMapper.createObjectNode();
body.put("access_token", (String) authentication.get("AccessToken"));
body.put("id_token", (String) authentication.get("IdToken"));
@@ -267,6 +313,67 @@ public class CognitoOAuthController {
}
}
private Response refreshTokens(String clientId, String clientSecret, MultivaluedMap<String, String> formParams,
String domainPoolId) {
String refreshToken = trimToNull(formParams.getFirst("refresh_token"));
if (refreshToken == null) {
return oauthError("invalid_request", "refresh_token is required");
}
UserPoolClient client;
try {
client = cognitoService.findClientById(clientId);
} catch (AwsException e) {
return oauthError("invalid_client", "Client not found");
}
if (domainPoolId != null && !domainPoolId.equals(client.getUserPoolId())) {
return oauthError("invalid_client", "Client not found");
}
if (client.getClientSecret() != null && !client.getClientSecret().isBlank()
&& !secretsEqual(client.getClientSecret(), clientSecret)) {
return oauthError("invalid_client", "Client secret is invalid");
}
try {
Map<String, Object> result = cognitoService.initiateAuth(clientId, "REFRESH_TOKEN_AUTH",
Map.of("REFRESH_TOKEN", refreshToken));
@SuppressWarnings("unchecked")
Map<String, Object> authentication = (Map<String, Object>) result.get("AuthenticationResult");
ObjectNode body = objectMapper.createObjectNode();
body.put("access_token", (String) authentication.get("AccessToken"));
body.put("id_token", (String) authentication.get("IdToken"));
body.put("expires_in", ((Number) authentication.get("ExpiresIn")).longValue());
body.put("token_type", (String) authentication.get("TokenType"));
return Response.ok(body).type(MediaType.APPLICATION_JSON)
.header("Cache-Control", "no-store").header("Pragma", "no-cache").build();
} catch (AwsException e) {
if ("NotAuthorizedException".equals(e.getErrorCode())) {
return oauthError("invalid_grant", e.getMessage());
}
if ("InvalidParameterException".equals(e.getErrorCode())) {
return oauthError("unauthorized_client", e.getMessage());
}
LOG.error("Failed to refresh Cognito tokens", e);
return oauthError("invalid_request", e.getMessage());
}
}
/** RFC 7636 S256: the challenge is BASE64URL(SHA-256(verifier)). No challenge, nothing to check. */
static boolean pkceVerified(String codeChallenge, String codeVerifier) {
if (codeChallenge == null) {
return true;
}
if (codeVerifier == null || codeVerifier.length() < 43 || codeVerifier.length() > 128) {
return false;
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
codeChallenge.getBytes(StandardCharsets.US_ASCII));
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private Response redirect(String redirectUri, Map<String, String> parameters) {
StringBuilder location = new StringBuilder(redirectUri);
location.append(redirectUri.contains("?") ? '&' : '?');
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
String confirmationCode) {
UserPool pool = poolStore.get(userPoolId)
.orElseThrow(() -> userPoolNotFound(userPoolId));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) throw codeMismatch();
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
400));
UserPool pool = describeUserPool(client.getUserPoolId());
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) return simulatedCodeDelivery(username);
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
throw new AwsException("NotAuthorizedException",
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
@@ -2695,6 +2697,14 @@ public class CognitoService implements ResourceProvider {
// ──────────────────────────── Auth ────────────────────────────
public CognitoUser authenticateHostedLogin(UserPoolClient client, String username, String password) {
return authFlowHandler.authenticateHostedLogin(describeUserPool(client.getUserPoolId()), client, username, password);
}
public Map<String, Object> issueHostedTokens(UserPoolClient client, CognitoUser user, String nonce) {
return authFlowHandler.issueHostedTokens(describeUserPool(client.getUserPoolId()), client, user, nonce);
}
public Map<String, Object> initiateAuth(String clientId, String authFlow, Map<String, String> authParameters) {
return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of());
}
@@ -2766,7 +2776,8 @@ public class CognitoService implements ResourceProvider {
public Map<String, Object> forgotPassword(String clientId, String username) {
UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) return new LinkedHashMap<>(simulatedCodeDelivery(username));
UserPool pool = describeUserPool(client.getUserPoolId());
ensureVerificationWiring();
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
@@ -2792,7 +2803,8 @@ public class CognitoService implements ResourceProvider {
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) throw codeMismatch();
ensureVerificationWiring();
try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -3067,6 +3079,18 @@ public class CognitoService implements ResourceProvider {
return overrideId;
}
/**
* The signing keys, by kid, of this emulator's own pool that {@code issuer} names exactly, so a
* JWT authorizer can verify the pool's tokens in-process; empty for every other issuer.
*/
public Optional<Map<String, RSAPublicKey>> localIssuerKeys(String issuer) {
if (issuer == null) return Optional.empty();
String trimmed = issuer.replaceAll("/+$", "");
String poolId = trimmed.substring(trimmed.lastIndexOf('/') + 1);
if (poolId.isEmpty() || !getIssuer(poolId).equals(trimmed)) return Optional.empty();
return poolStore.get(poolId).map(pool -> Map.of(getSigningKeyId(pool), getSigningPublicKey(pool)));
}
public String getJwksUri(String poolId) {
return getIssuer(poolId) + "/.well-known/jwks.json";
}
@@ -4438,10 +4462,38 @@ public class CognitoService implements ResourceProvider {
}
}
/**
* Whether the app client hides which usernames exist (PreventUserExistenceErrors=ENABLED). Real Cognito then
* answers for an unknown user exactly as for a real one: a failed sign-in is "Incorrect username or password",
* a code request reports a simulated delivery and a code check reports a wrong code. Unset means LEGACY.
*/
static boolean hidesUserExistence(UserPoolClient client) {
return "ENABLED".equals(client.getPreventUserExistenceErrors());
}
/** The user, or null when it does not exist and the client hides that; otherwise UserNotFoundException. */
private CognitoUser userForClientOrNull(UserPoolClient client, String username) {
try {
return adminGetUser(client.getUserPoolId(), username);
} catch (AwsException e) {
if ("UserNotFoundException".equals(e.getErrorCode()) && hidesUserExistence(client)) return null;
throw e;
}
}
private Map<String, String> simulatedCodeDelivery(String username) {
return username != null && username.contains("@")
? Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", maskEmail(username))
: Map.of("AttributeName", "phone_number", "DeliveryMedium", "SMS", "Destination", "+*******0000");
}
private static AwsException codeMismatch() {
return new AwsException("CodeMismatchException", "Invalid verification code provided, please try again.", 400);
}
private AwsException mapVerificationCodeException(VerificationCodeException e) {
return switch (e.getKind()) {
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException",
"Invalid verification code provided, please try again.", 400);
case MISMATCH, NOT_FOUND -> codeMismatch();
case EXPIRED -> new AwsException("ExpiredCodeException",
"Invalid code provided, please request a code again.", 400);
case RATE_LIMIT -> new AwsException("LimitExceededException",
@@ -66,7 +66,7 @@ public class CognitoWellKnownController {
String authorizationEndpoint = tokenEndpoint.replace("/token", "/authorize");
String body = """
{"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]}
{"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]}
""".formatted(issuer, jwksUri, authorizationEndpoint, tokenEndpoint, userInfoEndpoint).strip();
return Response.ok(body).build();
}
@@ -3,15 +3,27 @@ package io.github.hectorvent.floci.services.cognito.model;
import java.time.Instant;
import java.util.List;
/**
* A one-time code handed to the client's redirect URI. {@code codeChallenge} is the PKCE S256
* challenge from the authorize request, and {@code nonce} is copied into the ID token; both are
* null when the client did not send them.
*/
public record CognitoAuthorizationCode(
String userPoolId,
String clientId,
String userId,
String redirectUri,
List<String> scopes,
Instant expiresAt) {
Instant expiresAt,
String nonce,
String codeChallenge) {
public CognitoAuthorizationCode {
scopes = List.copyOf(scopes);
}
public CognitoAuthorizationCode(String userPoolId, String clientId, String userId, String redirectUri,
List<String> scopes, Instant expiresAt) {
this(userPoolId, clientId, userId, redirectUri, scopes, expiresAt, null, null);
}
}
@@ -11,9 +11,16 @@ public record CognitoAuthorizationTransaction(
String nonce,
String providerName,
String relyingPartyState,
Instant expiresAt) {
Instant expiresAt,
String codeChallenge) {
public CognitoAuthorizationTransaction {
scopes = List.copyOf(scopes);
}
public CognitoAuthorizationTransaction(String userPoolId, String clientId, String redirectUri, List<String> scopes,
String nonce, String providerName, String relyingPartyState,
Instant expiresAt) {
this(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt, null);
}
}
@@ -0,0 +1,7 @@
package io.github.hectorvent.floci.services.cognito.model;
import java.time.Instant;
/** A signed-in browser on the hosted login pages, so the next authorize request skips the form. */
public record CognitoHostedSession(String userPoolId, String username, Instant expiresAt) {
}
@@ -3,13 +3,19 @@ package io.github.hectorvent.floci.services.cognito.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Information about a revoked token for tracking and validation purposes.
* Used to implement AWS Cognito AdminUserGlobalSignOut token revocation behavior.
*
* <p>{@code expired} is derived from {@code expiresAt}, so it is not stored. Files written before
* that still carry it, and are read with it ignored instead of being discarded as corrupt.
*/
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
public class RevokedTokenInfo {
private final String jti;
@@ -62,6 +68,7 @@ public class RevokedTokenInfo {
/**
* Check if this revocation record has expired and can be cleaned up.
*/
@JsonIgnore
public boolean isExpired() {
return System.currentTimeMillis() / 1000L > expiresAt;
}
@@ -53,11 +53,16 @@ public class AmiImageResolver {
}
return new ResolvedAmiImage(
image.dockerImage,
image.guestRuntime == null || image.guestRuntime.isBlank()
image.initType == null || image.initType.isBlank()
? ResolvedAmiImage.DEFAULT_RUNTIME
: image.guestRuntime,
: image.initType,
Boolean.TRUE.equals(image.cloudInit),
dockerPlatform(image.architecture));
dockerPlatform(image.architecture),
image.fallbackDockerImage,
image.fallbackDockerImage == null ? "public" : "enhanced",
image.sshUser == null || image.sshUser.isBlank() ? "root" : image.sshUser,
Boolean.TRUE.equals(image.sshPreinstalled),
image.sshServiceName == null || image.sshServiceName.isBlank() ? "sshd" : image.sshServiceName);
}
private static String dockerPlatform(String architecture) {
@@ -12,6 +12,7 @@ import io.github.hectorvent.floci.core.common.docker.ContainerStorageHelper;
import io.github.hectorvent.floci.core.common.docker.DockerHostResolver;
import io.github.hectorvent.floci.core.common.docker.PortAllocator;
import io.github.hectorvent.floci.services.ec2.model.Instance;
import io.github.hectorvent.floci.services.ec2.model.InstanceLaunchEvent;
import io.github.hectorvent.floci.services.ec2.model.InstanceNetworkInterface;
import io.github.hectorvent.floci.services.ec2.model.InstanceState;
import io.github.hectorvent.floci.services.ec2.model.GroupIdentifier;
@@ -38,8 +39,13 @@ import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
@@ -60,6 +66,7 @@ import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -161,6 +168,8 @@ public class Ec2ContainerManager {
static int containerBridgeIpAttempts = 30;
static long containerBridgeIpPollMillis = 500;
static int sshBannerAttempts = 30;
static long sshBannerPollMillis = 1_000;
private final ContainerBuilder containerBuilder;
private final ContainerLifecycleManager lifecycleManager;
@@ -180,9 +189,14 @@ public class Ec2ContainerManager {
private final ExecutorService executor;
private final Duration userDataExecutionTimeout;
private final Set<ResultCallback<Frame>> activeUserDataCallbacks = ConcurrentHashMap.newKeySet();
private volatile Consumer<Instance> instancePersister = ignored -> {};
private volatile boolean dockerUnavailableLogged;
public void setInstancePersister(Consumer<Instance> instancePersister) {
this.instancePersister = instancePersister == null ? ignored -> {} : instancePersister;
}
public void refreshSecurityGroups(String region, Map<String, SecurityGroup> groups,
Map<String, List<String>> prefixLists) {
if (firewallManager != null && firewallManager.enabled()) {
@@ -240,7 +254,7 @@ public class Ec2ContainerManager {
InstanceNetworkInterface eni = instance.getNetworkInterfaces().getFirst();
String logical = instance.getLogicalPrivateIpAddress() != null
? instance.getLogicalPrivateIpAddress() : eni.getPrivateIpAddress();
firewallManager.register(new SecurityGroupNftCompiler.Endpoint(regionResolver.getAccountId(), region,
firewallManager.register(new SecurityGroupNftCompiler.Endpoint(accountId(instance), region,
instance.getVpcId(), eni.getNetworkInterfaceId(), logical, address,
instance.getSecurityGroups().stream().map(GroupIdentifier::getGroupId)
.collect(Collectors.toSet()), groups), helperId, prefixLists);
@@ -375,6 +389,9 @@ public class Ec2ContainerManager {
Set<Integer> appPorts, List<SecurityGroup> groups,
Map<String, List<String>> prefixLists) {
instance.setState(InstanceState.pending());
instance.setLaunchStatus("in-progress");
instance.setSshStatus("initializing");
persist(instance);
// An instance record is metadata: id, addresses, tags and lifecycle state are all
// served without a container runtime. Only the guest itself (SSH, UserData, SSM
@@ -398,11 +415,13 @@ public class Ec2ContainerManager {
try {
executor.execute(() -> {
try {
recordLaunchEvent(instance, "selecting-image", "started", "Selecting the launch image");
ResolvedAmiImage launchImage = selectLaunchImage(instance, image);
String instanceId = instance.getInstanceId();
// IMDS endpoint that this container should use
String flociHost = dockerHostResolver.resolve();
int imdsPort = config.services().ec2().imdsPort();
StartedContainer started = createAndStartContainer(instance, image, region, flociHost, imdsPort,
StartedContainer started = createAndStartContainer(instance, launchImage, region, flociHost, imdsPort,
leasedPrivateIp, groups, prefixLists);
if (started == null) {
return;
@@ -485,6 +504,7 @@ public class Ec2ContainerManager {
failLaunch(instance, leasedPrivateIp);
return;
}
persist(instance);
// Set public-facing addresses only for instances whose subnet
// opts in via MapPublicIpOnLaunch (#1984). Private-subnet
@@ -508,14 +528,47 @@ public class Ec2ContainerManager {
// Inject SSH public key, if one was provided
if (publicKey != null && !publicKey.isBlank()) {
injectSshKey(containerId, publicKey);
recordLaunchEvent(instance, "injecting-key", "started", "Injecting the selected public key");
OperationResult injection = injectSshKey(containerId, publicKey);
instance.setKeyInjectionStatus(injection.success() ? "succeeded" : "failed");
recordLaunchEvent(instance, "injecting-key", injection.success() ? "completed" : "failed",
injection.message());
} else {
instance.setKeyInjectionStatus("skipped");
recordLaunchEvent(instance, "injecting-key", "skipped", "No key pair was selected");
}
// sshd runs on every instance regardless of whether a key pair was supplied,
// matching real AWS AMIs (which start it as part of normal boot, independent of
// key-pair association) - starting it only when a key was present meant
// run-instances without --key-name produced a "connection refused" instead of AWS's
// actual behavior: a running daemon with simply nothing to authenticate against.
startSshd(containerId, instanceId);
// Systemd-backed AMIs start their enabled SSH unit during boot. Starting a second
// daemon here races it for port 22; lightweight guests still need this bootstrap.
if (!launchImage.systemd()) {
recordLaunchEvent(instance, "installing-ssh", "started", "Checking and installing SSH packages");
SshSetupResult setup = startSshd(containerId, instanceId);
String installationStatus = setup.installationAttempted()
? setup.installationSucceeded() ? "completed" : "failed"
: "skipped";
recordLaunchEvent(instance, "installing-ssh", installationStatus,
setup.installationMessage());
recordLaunchEvent(instance, "starting-ssh", setup.success() ? "completed" : "failed",
setup.message());
} else {
recordLaunchEvent(instance, "installing-ssh", "skipped",
"SSH is preinstalled in the selected image");
recordLaunchEvent(instance, "starting-ssh", "skipped",
"SSH startup is managed by " + launchImage.sshServiceName());
}
recordLaunchEvent(instance, "ssh-ready", "started", "Waiting for an SSH protocol banner");
OperationResult readiness = waitForSshBanner(instance.getContainerBridgeIp(), 22);
String readinessMessage = setupFailureMessage(instance, readiness.message());
instance.setSshStatus(readiness.success() ? "ok" : "impaired");
instance.setSshStatusMessage(readinessMessage);
instance.setLaunchStatus(readiness.success() ? "ready" : "failed");
if (!readiness.success()) {
instance.setStateReasonCode("Client.SSHUnavailable");
instance.setStateReasonMessage(readinessMessage);
instance.setStateTransitionReason(readinessMessage);
}
recordLaunchEvent(instance, "ssh-ready", readiness.success() ? "completed" : "failed",
readinessMessage);
// Execute UserData
String userData = instance.getUserData();
@@ -525,14 +578,14 @@ public class Ec2ContainerManager {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failLaunch(instance, leasedPrivateIp);
failLaunch(instance, leasedPrivateIp, "Launch interrupted");
} catch (Exception e) {
LOG.warnv("Failed to launch EC2 instance {0}: {1}", instance.getInstanceId(), e.getMessage());
// The daemon can disappear between the probe above and any of the calls in
// this block. Losing Docker is not the instance's fault, so degrade to a
// metadata-only instance; a genuine container failure still fails the launch.
if ((firewallManager != null && firewallManager.enabled()) || isDockerAvailable()) {
failLaunch(instance, leasedPrivateIp);
failLaunch(instance, leasedPrivateIp, errorMessage(e));
} else {
markContainerlessRunning(instance);
}
@@ -541,10 +594,51 @@ public class Ec2ContainerManager {
} catch (RejectedExecutionException e) {
LOG.warnv("Could not schedule EC2 instance {0} launch because the launch executor is saturated or stopping",
instance.getInstanceId());
failLaunch(instance, leasedPrivateIp);
failLaunch(instance, leasedPrivateIp,
"Launch executor is saturated or stopping: " + errorMessage(e));
}
}
ResolvedAmiImage selectLaunchImage(ResolvedAmiImage image) {
return selectLaunchImage(null, image);
}
private ResolvedAmiImage selectLaunchImage(Instance instance, ResolvedAmiImage image) {
if (image.fallbackDockerImage() == null || image.fallbackDockerImage().isBlank()) {
recordSelectedImage(instance, image, "Selected " + image.imageType() + " image");
return image;
}
String preferred = containerBuilder.resolveImage(image.dockerImage());
try {
dockerClient.inspectImageCmd(preferred).exec();
recordSelectedImage(instance, image, "Enhanced AMI image is available locally");
return image;
} catch (NotFoundException expected) {
LOG.warnv("EC2 AMI image {0} is not present locally; using pullable fallback {1}",
image.dockerImage(), image.fallbackDockerImage());
ResolvedAmiImage fallback = new ResolvedAmiImage(image.fallbackDockerImage(),
ResolvedAmiImage.DEFAULT_RUNTIME, false, image.dockerPlatform(), null,
"public-fallback", image.sshUser(), false, image.sshServiceName());
recordSelectedImage(instance, fallback,
"Enhanced image " + image.dockerImage() + " was not present locally; selected public fallback");
return fallback;
}
}
private void recordSelectedImage(Instance instance, ResolvedAmiImage image, String reason) {
if (instance == null) {
return;
}
instance.setSelectedImageType(image.imageType());
instance.setSelectedImageReference(image.dockerImage());
instance.setSshUser(image.sshUser());
instance.setSshPreinstalled(image.sshPreinstalled());
instance.setInitType(image.initType());
instance.setSshServiceName(image.sshServiceName());
recordLaunchEvent(instance, "selecting-image", "completed",
reason + ": " + image.dockerImage());
}
private StartedContainer createAndStartContainer(Instance instance, ResolvedAmiImage image, String region,
String flociHost, int imdsPort, String leasedPrivateIp,
List<SecurityGroup> groups,
@@ -558,9 +652,20 @@ public class Ec2ContainerManager {
if (isLaunchCancelled(instance)) {
return null;
}
int sshHostPort = portAllocator.allocate(
config.services().ec2().sshPortRangeStart(),
config.services().ec2().sshPortRangeEnd());
recordLaunchEvent(instance, "allocating-ssh-port", "started",
"Allocating a host port for guest port 22");
List<Integer> occupiedPorts = new ArrayList<>();
int sshHostPort;
try {
sshHostPort = portAllocator.allocate(
config.services().ec2().sshPortRangeStart(),
config.services().ec2().sshPortRangeEnd(),
occupiedPorts::add);
} catch (RuntimeException e) {
recordOccupiedPorts(instance, occupiedPorts);
throw e;
}
recordOccupiedPorts(instance, occupiedPorts);
SecurityGroupFirewallManager.Namespace namespace = null;
String eniId = null;
String containerId = null;
@@ -574,8 +679,8 @@ public class Ec2ContainerManager {
eniId = eni.getNetworkInterfaceId();
instance.setLogicalPrivateIpAddress(eni.getPrivateIpAddress());
namespace = firewallManager.createNamespace("ec2", instanceId,
regionResolver.getAccountId(), region, Optional.empty(), Map.of(22, sshHostPort));
firewallManager.register(new SecurityGroupNftCompiler.Endpoint(regionResolver.getAccountId(),
accountId(instance), region, Optional.empty(), Map.of(22, sshHostPort));
firewallManager.register(new SecurityGroupNftCompiler.Endpoint(accountId(instance),
region, instance.getVpcId(), eniId, eni.getPrivateIpAddress(),
namespace.transportAddress(),
instance.getSecurityGroups().stream().map(g -> g.getGroupId())
@@ -583,10 +688,34 @@ public class Ec2ContainerManager {
namespace.helperId(), prefixLists);
}
ContainerSpec spec = buildContainerSpec(containerName, image, region, serviceEndpoint, imdsEndpoint,
instanceId, sshHostPort, namespace, instance.getIamInstanceProfileArn() != null);
containerId = image.dockerPlatform() == null
? lifecycleManager.create(spec)
: lifecycleManager.create(spec, image.dockerPlatform());
instanceId, accountId(instance), sshHostPort, namespace,
instance.getIamInstanceProfileArn() != null);
recordLaunchEvent(instance, "allocating-ssh-port", "completed",
"Allocated SSH host port " + sshHostPort);
containerId = lifecycleManager.create(spec, image.dockerPlatform(),
new ContainerLifecycleManager.ContainerCreationObserver() {
@Override
public void imagePullStarted(String imageUri) {
recordLaunchEvent(instance, "pulling-image", "started", "Pulling " + imageUri);
}
@Override
public void imagePullCompleted(String imageUri) {
recordLaunchEvent(instance, "pulling-image", "completed", "Pulled " + imageUri);
}
@Override
public void containerCreationStarted() {
recordLaunchEvent(instance, "creating-container", "started",
"Creating the instance container");
}
@Override
public void containerCreated(String createdContainerId) {
recordLaunchEvent(instance, "creating-container", "completed",
"Created container " + createdContainerId);
}
});
if (!recordCreatedContainer(instance, containerId, sshHostPort)) {
lifecycleManager.removeIfExists(containerId);
if (namespace != null) {
@@ -596,15 +725,20 @@ public class Ec2ContainerManager {
portAllocator.release(sshHostPort);
return null;
}
persist(instance);
recorded = true;
// Join the VPC's Docker network at the address the subnet allocated, before the
// container starts, so the guest comes up already holding its private IP. The
// default bridge attachment stays: it is what carries the published SSH host
// port, which a network mode set at creation time would suppress.
recordLaunchEvent(instance, "configuring-network", "started",
"Attaching the instance container to its VPC network");
String vpcAddress = namespace == null
? vpcNetworkManager.attach(region, instance.getVpcId(), instance.getSubnetId(),
containerId, leasedPrivateIp).map(network -> leasedPrivateIp).orElse(null)
: null;
recordLaunchEvent(instance, "configuring-network", "completed",
vpcAddress == null ? "Using the container network namespace" : "Attached at " + vpcAddress);
lifecycleManager.startCreated(containerId, spec);
return new StartedContainer(containerId, sshHostPort, vpcAddress, namespace, eniId);
} catch (Exception e) {
@@ -627,6 +761,8 @@ public class Ec2ContainerManager {
portAllocator.markReserved(sshHostPort);
LOG.warnv("EC2 instance {0} could not use SSH host port {1}; trying another port",
instanceId, String.valueOf(sshHostPort));
recordLaunchEvent(instance, "allocating-ssh-port", "info",
"SSH host port " + sshHostPort + " was occupied; selecting another port");
continue;
}
portAllocator.release(sshHostPort);
@@ -637,6 +773,7 @@ public class Ec2ContainerManager {
private ContainerSpec buildContainerSpec(String containerName, ResolvedAmiImage image, String region,
String serviceEndpoint, String imdsEndpoint, String instanceId,
String accountId,
int sshHostPort, SecurityGroupFirewallManager.Namespace namespace,
boolean hasInstanceProfile) {
// Minimal images keep the historic tail command, while cloud-image AMI guests can boot their init.
@@ -650,7 +787,7 @@ public class Ec2ContainerManager {
.withHostDockerInternalOnLinux()
.withLogRotation()
.withLabels(ContainerStorageHelper.resourceIdentityLabels(
"ec2", instanceId, regionResolver.getAccountId(), region))
"ec2", instanceId, accountId, region))
// Which Floci owns this container, so the startup reconciler cannot reap a
// sibling emulator's live instances off a shared daemon. See LABEL_OWNER_PORT.
.withLabels(Map.of(LABEL_OWNER_PORT, ownerIdentity()))
@@ -675,7 +812,7 @@ public class Ec2ContainerManager {
}
private void failLaunch(Instance instance) {
failLaunch(instance, instance.getPrivateIpAddress());
failLaunch(instance, instance.getPrivateIpAddress(), "EC2 container launch failed");
}
/**
@@ -688,6 +825,10 @@ public class Ec2ContainerManager {
* reserved while the endpoint stands, and would refuse the next launch handed the same one.
*/
private void failLaunch(Instance instance, String leasedPrivateIp) {
failLaunch(instance, leasedPrivateIp, "EC2 container launch failed");
}
private void failLaunch(Instance instance, String leasedPrivateIp, String reason) {
String containerId;
int sshHostPort;
String containerIp;
@@ -698,6 +839,13 @@ public class Ec2ContainerManager {
&& instance.getSshHostPort() <= 0
&& (instance.getContainerBridgeIp() == null || instance.getContainerBridgeIp().isBlank());
instance.setState(InstanceState.terminated());
instance.setStateReasonCode("Server.InternalError");
instance.setStateReasonMessage(reason);
instance.setStateTransitionReason(reason);
instance.setTerminatedAt(System.currentTimeMillis());
instance.setLaunchStatus("failed");
instance.setSshStatus("impaired");
instance.setSshStatusMessage(reason);
containerId = instance.getDockerContainerId();
sshHostPort = instance.getSshHostPort();
containerIp = instance.getContainerBridgeIp();
@@ -705,6 +853,7 @@ public class Ec2ContainerManager {
instance.setSshHostPort(0);
instance.setContainerBridgeIp(null);
}
recordLaunchEvent(instance, currentLaunchStage(instance), "failed", reason);
if (alreadyCleaned) {
return;
}
@@ -743,6 +892,7 @@ public class Ec2ContainerManager {
LOG.warnv("Error unregistering failed EC2 instance {0}: {1}", instance.getInstanceId(), e.getMessage());
}
}
persist(instance);
}
/**
@@ -857,7 +1007,27 @@ public class Ec2ContainerManager {
private void markContainerlessRunning(Instance instance) {
LOG.infov("EC2 instance {0} is running without a backing container (no Docker daemon reachable)",
instance.getInstanceId());
String reason = "Docker daemon is unavailable; the instance has no backing container";
instance.setState(InstanceState.running());
instance.setLaunchStatus("failed");
instance.setSshStatus("impaired");
instance.setSshStatusMessage(reason);
instance.setStateReasonCode("Server.InternalError");
instance.setStateReasonMessage(reason);
instance.setStateTransitionReason(reason);
recordLaunchEvent(instance, "creating-container", "failed", reason);
}
private String accountId(Instance instance) {
return instance.getAccountId() == null || instance.getAccountId().isBlank()
? regionResolver.getAccountId() : instance.getAccountId();
}
private void recordOccupiedPorts(Instance instance, List<Integer> occupiedPorts) {
for (Integer occupiedPort : occupiedPorts) {
recordLaunchEvent(instance, "allocating-ssh-port", "retrying",
"SSH host port " + occupiedPort + " is occupied; trying the next port");
}
}
/**
@@ -1228,6 +1398,15 @@ public class Ec2ContainerManager {
* Sets terminatedAt for TTL pruning.
*/
public void terminate(Instance instance) {
terminate(instance, () -> {
});
}
/**
* Terminates an instance and invokes {@code onTerminated} after its final state is recorded.
* The caller persists the instance because this manager deliberately has no storage dependency.
*/
public void terminate(Instance instance, Runnable onTerminated) {
String containerId;
String containerIp;
String imdsSourceIp;
@@ -1273,6 +1452,7 @@ public class Ec2ContainerManager {
instance.getPrivateIpAddress());
instance.setState(InstanceState.terminated());
instance.setTerminatedAt(System.currentTimeMillis());
onTerminated.run();
});
}
@@ -1287,6 +1467,13 @@ public class Ec2ContainerManager {
executor.submit(() -> {
try {
dockerClient.restartContainerCmd(containerId).exec();
InspectContainerResponse container = dockerClient.inspectContainerCmd(containerId).exec();
if (isLegacyTailGuest(container.getConfig().getCmd())) {
// Instances launched before systemd-backed AMIs used tail as PID 1. Their image
// catalog entry now identifies a systemd runtime, but Docker restart retains
// the original command and therefore does not start the SSH service.
startSshd(containerId, instance.getInstanceId());
}
LOG.infov("Rebooted EC2 container {0}", containerId);
} catch (Exception e) {
LOG.warnv("Error rebooting EC2 container {0}: {1}", containerId, e.getMessage());
@@ -1294,6 +1481,11 @@ public class Ec2ContainerManager {
});
}
static boolean isLegacyTailGuest(String[] command) {
return command != null && command.length == 3
&& "tail".equals(command[0]) && "-f".equals(command[1]) && "/dev/null".equals(command[2]);
}
public boolean isContainerRunning(Instance instance) {
String containerId = instance.getDockerContainerId();
return containerId != null && lifecycleManager.isContainerRunning(containerId);
@@ -1369,7 +1561,7 @@ public class Ec2ContainerManager {
}
}
private void injectSshKey(String containerId, String publicKey) {
private OperationResult injectSshKey(String containerId, String publicKey) {
try {
// Ensure .ssh directory exists with correct permissions
execInContainer(containerId, new String[]{"sh", "-c",
@@ -1385,32 +1577,39 @@ public class Ec2ContainerManager {
execInContainer(containerId, new String[]{"chmod", "600", "/root/.ssh/authorized_keys"}, 5);
LOG.infov("Injected SSH public key into container {0}", containerId);
return new OperationResult(true, "Public key injected into /root/.ssh/authorized_keys");
} catch (Exception e) {
LOG.warnv("Could not inject SSH key into container {0}: {1}", containerId, e.getMessage());
return new OperationResult(false, errorMessage(e));
}
}
private void startSshd(String containerId, String instanceId) {
private SshSetupResult startSshd(String containerId, String instanceId) {
try {
// Exit 1 means sshd is absent and there is nothing to start; exit 2 means sshd is
// there but the client package did not land. Only the first is fatal - see
// sshdInstallProbeCommand() for why the probe distinguishes them.
ContainerExecResult install = execInContainerForResult(containerId, sshdInstallProbeCommand(), 120);
boolean installationAttempted = install.output().contains("FLOCI_SSH_INSTALL=1");
if (install.exitCode() == SSH_CLIENT_MISSING_EXIT_CODE) {
LOG.warnv("sshd is available on EC2 instance {0} but the OpenSSH client package is not:"
+ " scp is missing, so provisioners that upload files over scp will fail: {1}",
instanceId, install.summary());
} else if (install.exitCode() != 0) {
String message = "Could not install openssh-server: " + install.summary();
LOG.warnv("Could not install openssh-server for EC2 instance {0}: {1}",
instanceId, install.summary());
return;
return new SshSetupResult(false, installationAttempted, false,
installationAttempted ? "SSH package installation failed" : "SSH package installation skipped",
message);
}
// Generate host keys
ContainerExecResult keygen = execInContainerForResult(containerId, new String[]{"ssh-keygen", "-A"}, 10);
if (keygen.exitCode() != 0) {
LOG.warnv("Could not generate SSH host keys for EC2 instance {0}: {1}",
instanceId, keygen.summary());
return;
return new SshSetupResult(false, installationAttempted, true, "SSH packages are available",
"Could not generate SSH host keys: " + keygen.summary());
}
// Modern OpenSSH refuses to start without its privilege-separation directory, and /run
// is a fresh tmpfs in most container images, so /run/sshd genuinely isn't there yet.
@@ -1419,7 +1618,8 @@ public class Ec2ContainerManager {
if (mkdir.exitCode() != 0) {
LOG.warnv("Could not create /run/sshd for EC2 instance {0}: {1}",
instanceId, mkdir.summary());
return;
return new SshSetupResult(false, installationAttempted, true, "SSH packages are available",
"Could not create /run/sshd: " + mkdir.summary());
}
// Start sshd without -D so it daemonizes itself and survives this exec session. Since sshd
// requires execution with an absolute path, several paths are tried until it starts
@@ -1430,13 +1630,108 @@ public class Ec2ContainerManager {
continue;
}
LOG.infov("Started sshd in EC2 instance {0}", instanceId);
return;
return new SshSetupResult(true, installationAttempted, true,
installationAttempted ? "SSH packages installed" : "SSH was already installed",
"Started sshd using " + sshdPath);
}
return new SshSetupResult(false, installationAttempted, true, "SSH packages are available",
"No supported sshd executable started successfully");
} catch (Exception e) {
LOG.warnv("Could not start sshd in EC2 instance {0}: {1}", instanceId, e.getMessage());
return new SshSetupResult(false, false, false, "SSH package state is unknown", errorMessage(e));
}
}
private OperationResult waitForSshBanner(String host, int port) throws InterruptedException {
if (host == null || host.isBlank()) {
return new OperationResult(false, "No container address is available for the SSH readiness check");
}
String lastError = "SSH did not return a protocol banner";
for (int attempt = 0; attempt < sshBannerAttempts; attempt++) {
OperationResult probe = probeSshBanner(host, port);
if (probe.success()) {
return probe;
}
lastError = probe.message();
if (attempt + 1 < sshBannerAttempts) {
Thread.sleep(sshBannerPollMillis);
}
}
return new OperationResult(false, lastError);
}
private OperationResult probeSshBanner(String host, int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 1_000);
socket.setSoTimeout(1_000);
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
String banner = reader.readLine();
if (banner != null && banner.startsWith("SSH-")) {
return new OperationResult(true, "SSH banner received: " + banner);
}
return new OperationResult(false, banner == null
? "SSH connection closed without a protocol banner"
: "Endpoint returned a non-SSH banner: " + banner);
} catch (IOException e) {
return new OperationResult(false, errorMessage(e));
}
}
public OperationResult checkSshReadiness(Instance instance) {
return probeSshBanner(instance.getContainerBridgeIp(), 22);
}
private void recordLaunchEvent(Instance instance, String stage, String status, String message) {
if (instance == null) {
return;
}
synchronized (instance) {
instance.getLaunchEvents().add(new InstanceLaunchEvent(stage, status, Instant.now(), message));
}
persist(instance);
}
private String setupFailureMessage(Instance instance, String readinessMessage) {
synchronized (instance) {
List<InstanceLaunchEvent> events = instance.getLaunchEvents();
for (int i = events.size() - 1; i >= 0; i--) {
InstanceLaunchEvent event = events.get(i);
if ("starting-ssh".equals(event.getStage()) && "failed".equals(event.getStatus())) {
return event.getMessage() + "; readiness check: " + readinessMessage;
}
}
}
return readinessMessage;
}
private String currentLaunchStage(Instance instance) {
synchronized (instance) {
List<InstanceLaunchEvent> events = instance.getLaunchEvents();
if (!events.isEmpty()) {
return events.getLast().getStage();
}
}
return "launch";
}
private void persist(Instance instance) {
instancePersister.accept(instance);
}
private static String errorMessage(Throwable error) {
if (error == null) {
return "Unknown launch failure";
}
String message = error.getMessage();
return message == null || message.isBlank() ? error.getClass().getSimpleName() : message;
}
public record OperationResult(boolean success, String message) {}
private record SshSetupResult(boolean success, boolean installationAttempted, boolean installationSucceeded,
String installationMessage, String message) {}
private void executeUserData(String containerId, String instanceId, String userData, String region) {
try {
String logGroup = "/aws/ec2/" + instanceId;
@@ -1741,7 +2036,43 @@ public class Ec2ContainerManager {
}
static String[] userDataExecutionCommand() {
return new String[]{USER_DATA_SCRIPT_PATH};
String command = """
policy=/usr/sbin/policy-rc.d
backup=/var/lib/floci-policy-rc.d
actions=/var/lib/floci-service-actions
rm -f "$actions"
if [ -f "$policy" ]; then mv "$policy" "$backup"; fi
printf '%s\n' '#!/bin/sh' \
'while [ "$#" -gt 0 ]; do case "$1" in --*) shift ;; *) break ;; esac; done' \
'service=$1' \
'action=$2' \
'action=${action#(}' \
'action=${action%)}' \
'case "$action" in start|restart|try-restart|reload|force-reload)' \
'printf "%s %s\\n" "$service" "$action" >> /var/lib/floci-service-actions ;; esac' \
'exit 101' > "$policy"
chmod 755 "$policy"
restore_policy_and_start_services() {
status=$?
rm -f "$policy"
if [ -f "$backup" ]; then mv "$backup" "$policy"; fi
if [ -f "$actions" ]; then
while read -r service action; do
if command -v service >/dev/null 2>&1; then
service "$service" "$action" || true
elif [ -x "/etc/init.d/$service" ]; then
"/etc/init.d/$service" "$action" || true
fi
done < "$actions"
rm -f "$actions"
fi
trap - EXIT
exit "$status"
}
trap restore_policy_and_start_services EXIT
/var/lib/user-data.sh
""";
return new String[]{"sh", "-c", command};
}
/**
@@ -1773,7 +2104,9 @@ public class Ec2ContainerManager {
*/
static String[] sshdInstallProbeCommand() {
return new String[]{"sh", "-c",
"if ! command -v sshd >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then" +
"installed=0;" +
"if ! command -v sshd >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then" +
" installed=1;" +
" if command -v dnf >/dev/null 2>&1; then dnf install -y openssh-server openssh-clients >/dev/null 2>&1;" +
// yum is probed after dnf, and the order is load-bearing: Amazon Linux
// 2023 and modern Fedora/RHEL ship both, and there dnf is the supported
@@ -1783,9 +2116,11 @@ public class Ec2ContainerManager {
// and sshd is never installed.
" elif command -v yum >/dev/null 2>&1; then yum install -y openssh-server openssh-clients >/dev/null 2>&1;" +
" elif command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server openssh-client >/dev/null 2>&1;" +
" elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install -y openssh >/dev/null 2>&1;" +
" elif command -v apk >/dev/null 2>&1; then apk add --no-cache openssh >/dev/null 2>&1;" +
" fi;" +
"fi;" +
"printf 'FLOCI_SSH_INSTALL=%s\\n' \"$installed\";" +
"command -v sshd >/dev/null 2>&1 || exit 1;" +
"command -v scp >/dev/null 2>&1 || exit 2"};
}
@@ -168,6 +168,7 @@ public class Ec2ImageCatalog {
public List<String> aliases = List.of();
public List<String> publicParameterNames = List.of();
public String dockerImage;
public String fallbackDockerImage;
public String name;
public String description;
public String state;
@@ -182,8 +183,11 @@ public class Ec2ImageCatalog {
public String imageOwnerAlias;
public String creationDate;
public Boolean advertised;
public String guestRuntime;
public String initType;
public Boolean cloudInit;
public String sshUser;
public Boolean sshPreinstalled;
public String sshServiceName;
public String provenance;
public boolean advertised() {
@@ -11,30 +11,34 @@ import java.util.Optional;
*/
public final class Ec2MetadataProxy {
private static final int IMDS_PROXY_PORT = 1338;
private Ec2MetadataProxy() {}
public static String[] installCommand() {
return new String[]{"sh", "-c", String.join("\n",
"set -eu",
"if command -v ip >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi",
"if command -v ip >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 && command -v socat >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then exit 0; fi",
"if command -v apt-get >/dev/null 2>&1; then",
" apt-get update -qq >/dev/null",
" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 socat curl ca-certificates >/dev/null",
" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 iptables socat curl ca-certificates >/dev/null",
"elif command -v dnf >/dev/null 2>&1; then",
// --allowerasing lets dnf swap the curl-minimal that
// public.ecr.aws/amazonlinux/amazonlinux:2023 ships by default for the full
// curl package this proxy needs. Without it, dnf aborts the whole transaction
// on a curl/curl-minimal conflict and iproute+socat never install either, even
// though neither of them conflicts with anything.
" dnf install -y --allowerasing iproute socat curl ca-certificates >/dev/null",
" dnf install -y --allowerasing iproute iptables socat curl ca-certificates >/dev/null",
// Same gap as the sshd probe: Amazon Linux 2 has only yum, so on an instance
// launched from ami-amazonlinux2 this chain reached its else branch and exited 1
// with "No supported package manager found for IMDS proxy dependencies",
// leaving the instance without a link-local IMDS endpoint.
"elif command -v yum >/dev/null 2>&1; then",
" yum install -y iproute socat curl ca-certificates >/dev/null",
" yum install -y iproute iptables socat curl ca-certificates >/dev/null",
"elif command -v zypper >/dev/null 2>&1; then",
" zypper --non-interactive install -y iproute2 iptables socat curl ca-certificates >/dev/null",
"elif command -v apk >/dev/null 2>&1; then",
" apk add --no-cache iproute2 socat curl ca-certificates >/dev/null",
" apk add --no-cache iproute2 iptables socat curl ca-certificates >/dev/null",
"else",
" echo 'No supported package manager found for IMDS proxy dependencies' >&2",
" exit 1",
@@ -42,8 +46,24 @@ public final class Ec2MetadataProxy {
}
public static String[] startCommand(String flociHost, int imdsPort) {
return startCommand("imds", "169.254.169.254", 80, flociHost, imdsPort,
"curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0");
return new String[]{"sh", "-c", String.join("\n",
"set -eu",
"ip addr show dev lo | grep -q '169.254.169.254/32' || ip addr add 169.254.169.254/32 dev lo",
"iptables -t nat -C OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:"
+ IMDS_PROXY_PORT + " 2>/dev/null || iptables -t nat -A OUTPUT -d 169.254.169.254 -p tcp --dport 80 -j DNAT --to-destination 127.0.0.1:"
+ IMDS_PROXY_PORT,
"if [ -f /tmp/floci-imds-proxy.pid ] && kill -0 \"$(cat /tmp/floci-imds-proxy.pid)\" 2>/dev/null; then",
" exit 0",
"fi",
"nohup socat TCP-LISTEN:" + IMDS_PROXY_PORT + ",bind=127.0.0.1,fork,reuseaddr TCP:"
+ flociHost + ":" + imdsPort + " >/tmp/floci-imds-proxy.log 2>&1 &",
"echo $! > /tmp/floci-imds-proxy.pid",
"for i in 1 2 3 4 5 6 7 8 9 10 11 12; do",
" curl -fsS --max-time 1 http://169.254.169.254/latest/meta-data/instance-id >/dev/null && exit 0",
" sleep 1",
"done",
"cat /tmp/floci-imds-proxy.log >&2 || true",
"exit 1")};
}
public static String[] podIdentityStartCommand(String flociHost, int flociPort) {
@@ -702,6 +702,15 @@ public class Ec2QueryHandler {
int networkInterfaceDeviceIndex = parseIntParam(p, "NetworkInterface.1.DeviceIndex", 0);
String clientToken = p.getFirst("ClientToken");
List<String> sgIds = getList(p, "SecurityGroupId");
if (sgIds.isEmpty()) {
sgIds = getList(p, "NetworkInterface.1.SecurityGroupId");
}
if (sgIds.isEmpty()) {
sgIds = getList(p, "NetworkInterface.1.Groups");
}
if (sgIds.isEmpty()) {
sgIds = getList(p, "NetworkInterface.1.GroupId");
}
// UserData is base64-encoded in the wire format
String userDataEncoded = p.getFirst("UserData");
@@ -1285,12 +1294,20 @@ public class Ec2QueryHandler {
private Response handleDescribeInstanceStatus(MultivaluedMap<String, String> p, String region) {
List<String> ids = getList(p, "InstanceId");
List<Instance> runningInstances = service.describeInstanceStatus(region, ids);
boolean includeAllInstances = Boolean.TRUE.equals(
parseOptionalBoolean(p.getFirst("IncludeAllInstances"), "IncludeAllInstances"));
List<Instance> instances = service.describeInstanceStatus(region, ids, includeAllInstances);
XmlBuilder xml = new XmlBuilder()
.start("DescribeInstanceStatusResponse", AwsNamespaces.EC2)
.elem("requestId", UUID.randomUUID().toString())
.start("instanceStatusSet");
for (Instance inst : runningInstances) {
for (Instance inst : instances) {
String reachability = inst.getSshStatus() == null ? "initializing" : inst.getSshStatus();
String detail = switch (reachability) {
case "ok" -> "passed";
case "impaired" -> "failed";
default -> "initializing";
};
xml.start("item")
.elem("instanceId", inst.getInstanceId())
.elem("availabilityZone", inst.getPlacement() != null ? inst.getPlacement().getAvailabilityZone() : "")
@@ -1305,9 +1322,9 @@ public class Ec2QueryHandler {
.end("item").end("details")
.end("systemStatus")
.start("instanceStatus")
.elem("status", "ok")
.elem("status", reachability)
.start("details").start("item")
.elem("name", "reachability").elem("status", "passed")
.elem("name", "reachability").elem("status", detail)
.end("item").end("details")
.end("instanceStatus")
.end("item");
@@ -21,6 +21,9 @@ import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiPredicate;
import java.util.function.Function;
@@ -31,6 +34,8 @@ import java.util.stream.Stream;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import io.github.hectorvent.floci.config.EmulatorConfig;
import io.github.hectorvent.floci.core.common.RequestContext;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
@@ -56,6 +61,7 @@ import io.github.hectorvent.floci.services.ec2.model.Image;
import io.github.hectorvent.floci.services.ec2.model.Instance;
import io.github.hectorvent.floci.services.ec2.model.InstanceCreditSpecification;
import io.github.hectorvent.floci.services.ec2.model.InstanceCreditSpecificationListResult;
import io.github.hectorvent.floci.services.ec2.model.InstanceLaunchEvent;
import io.github.hectorvent.floci.services.ec2.model.InstanceNetworkInterface;
import io.github.hectorvent.floci.services.ec2.model.InstanceState;
import io.github.hectorvent.floci.services.ec2.model.NetworkInterface;
@@ -216,6 +222,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
private final Set<String> seededAccountRegions = ConcurrentHashMap.newKeySet();
// subnetId → counter for IP assignment (runtime-only, not persisted)
private final Map<String, AtomicInteger> subnetIpCounters = new ConcurrentHashMap<>();
private ScheduledExecutorService terminatedInstancePruner;
/**
* Null in the hermetic unit tests, which reach the constructors that do not take it; CDI always
@@ -501,22 +508,21 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
@PostConstruct
void restoreMetadataRegistrations() {
containerManager.setInstancePersister(this::persistInstance);
if (portForwardManager != null) {
portForwardManager.setPersister(inst -> {
if (inst != null && inst.getRegion() != null && inst.getInstanceId() != null) {
instances.put(key(inst.getRegion(), inst.getInstanceId()), inst);
}
});
portForwardManager.setPersister(this::persistInstance);
}
clearPersistedPrivateKeyMaterial();
if (config.services().ec2().mock()) {
return;
}
restoreVpcNetworks();
recoverInterruptedTerminations();
int restored = 0;
for (String key : instances.keys()) {
Instance instance = instances.get(key).orElse(null);
for (AccountAwareStorageBackend.AccountEntry<Instance> entry : allInstanceEntries()) {
Instance instance = entry.value();
if (!needsMetadataRegistration(instance)) {
continue;
}
@@ -526,11 +532,26 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
LOG.warnv("Could not restore EC2 firewall for {0}: {1}", instance.getInstanceId(), e.getMessage());
containerManager.stopForShutdown(instance);
instance.setState(InstanceState.stopped());
instances.put(key, instance);
persistOwnedInstance(entry, instance);
continue;
}
if (containerManager.restoreMetadataRegistration(instance)) {
instances.put(key, instance);
if ("pending".equals(instance.getState().getName())
|| "in-progress".equals(instance.getLaunchStatus())) {
instance.setState(InstanceState.running());
instance.setLaunchStatus("unknown");
instance.getLaunchEvents().add(new InstanceLaunchEvent("startup-reconciliation", "unknown",
Instant.now(), "Core restarted before launch completion was recorded"));
Ec2ContainerManager.OperationResult readiness = containerManager.checkSshReadiness(instance);
if (readiness == null) {
instance.setSshStatus("unknown");
instance.setSshStatusMessage("SSH readiness could not be reconstructed after restart");
} else {
instance.setSshStatus(readiness.success() ? "ok" : "impaired");
instance.setSshStatusMessage(readiness.message());
}
}
persistOwnedInstance(entry, instance);
restored++;
// Container is running: re-reserve host ports and recreate any missing socat sidecars.
if (portForwardManager != null) {
@@ -538,6 +559,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
}
}
failUnrecoverablePendingLaunches();
if (restored > 0) {
LOG.infov("Restored IMDS metadata registration for {0} EC2 container(s)", restored);
}
@@ -547,6 +569,68 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
containerManager.reconcileOrphanedContainers(this::instanceContainerStillWanted);
}
private void failUnrecoverablePendingLaunches() {
for (AccountAwareStorageBackend.AccountEntry<Instance> entry : allInstanceEntries()) {
Instance instance = entry.value();
if (instance == null || instance.getState() == null
|| (!"pending".equals(instance.getState().getName())
&& !"in-progress".equals(instance.getLaunchStatus()))) {
continue;
}
String reason = "Launch state is unknown because core restarted before a backing container was recorded";
instance.setState(InstanceState.terminated());
instance.setLaunchStatus("failed");
instance.setSshStatus("impaired");
instance.setSshStatusMessage(reason);
instance.setStateReasonCode("Server.InternalError");
instance.setStateReasonMessage(reason);
instance.setStateTransitionReason(reason);
instance.setTerminatedAt(System.currentTimeMillis());
instance.getLaunchEvents().add(new InstanceLaunchEvent("startup-reconciliation", "failed",
Instant.now(), reason));
persistOwnedInstance(entry, instance);
}
}
private List<AccountAwareStorageBackend.AccountEntry<Instance>> allInstanceEntries() {
if (instances instanceof AccountAwareStorageBackend<?> rawAccountAware) {
@SuppressWarnings("unchecked")
AccountAwareStorageBackend<Instance> accountAware =
(AccountAwareStorageBackend<Instance>) rawAccountAware;
return accountAware.scanAllAccountEntries(key -> true);
}
return instances.keys().stream()
.map(storageKey -> instances.get(storageKey)
.map(instance -> new AccountAwareStorageBackend.AccountEntry<Instance>(
null, storageKey, instance)))
.flatMap(Optional::stream)
.toList();
}
private void persistOwnedInstance(AccountAwareStorageBackend.AccountEntry<Instance> entry, Instance instance) {
if (entry.accountId() != null && instances instanceof AccountAwareStorageBackend<?> rawAccountAware) {
@SuppressWarnings("unchecked")
AccountAwareStorageBackend<Instance> accountAware =
(AccountAwareStorageBackend<Instance>) rawAccountAware;
accountAware.putForAccount(entry.accountId(), entry.key(), instance);
return;
}
instances.put(entry.key(), instance);
}
private void persistInstance(Instance instance) {
if (instance == null || instance.getRegion() == null || instance.getInstanceId() == null) {
return;
}
String storageKey = key(instance.getRegion(), instance.getInstanceId());
if (instances instanceof AccountAwareStorageBackend<Instance> accountAware
&& instance.getAccountId() != null && !instance.getAccountId().isBlank()) {
accountAware.putForAccount(instance.getAccountId(), storageKey, instance);
return;
}
instances.put(storageKey, instance);
}
/**
* Whether a container labelled for (region, instanceId) still backs a live instance record.
* False means the record is gone or already terminated, so the container is an orphan.
@@ -560,6 +644,17 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
return !"terminated".equals(state) && !"shutting-down".equals(state);
}
private void recoverInterruptedTerminations() {
for (AccountAwareStorageBackend.AccountEntry<Instance> entry : allInstanceEntries()) {
Instance instance = entry.value();
if (instance == null || instance.getState() == null
|| !"shutting-down".equals(instance.getState().getName())) {
continue;
}
containerManager.terminate(instance, () -> persistOwnedInstance(entry, instance));
}
}
/**
* Rebuilds the in-memory VPC address plan from persisted VPCs and subnets, after clearing
* out any Docker networks a previous run of this same emulator left behind.
@@ -599,8 +694,8 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
private void reserveRestoredPrivateIps() {
int reserved = 0;
int skipped = 0;
for (String storageKey : instances.keys()) {
Instance instance = instances.get(storageKey).orElse(null);
for (AccountAwareStorageBackend.AccountEntry<Instance> entry : allInstanceEntries()) {
Instance instance = entry.value();
if (instance == null || instance.getSubnetId() == null
|| instance.getPrivateIpAddress() == null
|| instance.getState() == null
@@ -2627,6 +2722,10 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
LaunchTemplateData.MetadataOptions launchMetadataOptions = LaunchTemplateData.MetadataOptions.merge(
LaunchTemplateData.MetadataOptions.launchDefaults(), metadataOptions);
ensureDefaultResources(region);
if (keyName != null && findKeyPair(region, keyName) == null) {
throw new AwsException("InvalidKeyPair.NotFound",
"The key pair '" + keyName + "' does not exist", 400);
}
// floci-kt9: a caller-supplied primary ENI (override-default-eni) governs its own
// subnet/VPC/security-groups and can only ever back a single instance.
@@ -2721,8 +2820,9 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
dockerImage = amiImageResolver.resolveImage(resolveLaunchableImageId(region, imageId));
String captured = capturedImageFor(region, imageId);
if (captured != null) {
dockerImage = new ResolvedAmiImage(captured, dockerImage.guestRuntime(),
dockerImage.cloudInit(), dockerImage.dockerPlatform());
dockerImage = new ResolvedAmiImage(captured, dockerImage.initType(),
dockerImage.cloudInit(), dockerImage.dockerPlatform(), null, "captured",
dockerImage.sshUser(), dockerImage.sshPreinstalled(), dockerImage.sshServiceName());
}
}
if (dryRun) {
@@ -2757,6 +2857,11 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
inst.setAmiLaunchIndex(i);
inst.setClientToken(clientToken);
inst.setRegion(region);
inst.setAccountId(callerAccountId());
inst.setLaunchStatus(config.services().ec2().mock() ? "ready" : "in-progress");
inst.setSshStatus(config.services().ec2().mock() ? "ok" : "initializing");
inst.getLaunchEvents().add(new InstanceLaunchEvent("launch-requested", "completed",
Instant.now(), "RunInstances accepted the launch"));
inst.setUserData(userData);
inst.setEncodedUserData(encodedUserData);
inst.setIamInstanceProfileArn(iamInstanceProfileArn);
@@ -2836,13 +2941,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
// Outside the lock: the containers are what the lock protects a reference to, not part of
// the registry, and a launch is slow.
if (!config.services().ec2().mock()) {
String publicKey = null;
if (keyName != null) {
KeyPair kp = findKeyPair(region, keyName);
if (kp != null) {
publicKey = kp.getPublicKey();
}
}
String publicKey = keyName == null ? null : findKeyPair(region, keyName).getPublicKey();
for (Instance inst : launched) {
List<SecurityGroup> policyGroups = inst.getSecurityGroups().stream()
.map(group -> securityGroups.get(key(region, group.getGroupId()))
@@ -3082,6 +3181,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
public List<Reservation> describeInstances(String region, List<String> instanceIds, Map<String, List<String>> filters) {
pruneTerminatedInstances();
ensureDefaultResources(region);
if (!instanceIds.isEmpty()) {
for (String id : instanceIds) {
@@ -3130,7 +3230,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
inst.setState(InstanceState.terminated());
inst.setTerminatedAt(System.currentTimeMillis());
} else {
containerManager.terminate(inst);
containerManager.terminate(inst, () -> instances.put(key(region, id), inst));
}
// Delete root volume if deleteOnTermination (matches real AWS behavior)
if (inst.getRootVolumeId() != null) {
@@ -3274,7 +3374,7 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
}
/** Removes terminated instances older than 1 hour. Called periodically by lifecycle. */
/** Removes terminated instances older than 1 hour. */
public void pruneTerminatedInstances() {
long cutoff = System.currentTimeMillis() - 3_600_000L;
for (String storeKey : new ArrayList<>(instances.keys())) {
@@ -3288,7 +3388,35 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
}
}
public List<Instance> describeInstanceStatus(String region, List<String> instanceIds) {
@PostConstruct
void startTerminatedInstancePruner() {
terminatedInstancePruner = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "ec2-terminated-instance-pruner");
thread.setDaemon(true);
return thread;
});
terminatedInstancePruner.scheduleAtFixedRate(this::pruneTerminatedInstancesSafely,
1, 1, TimeUnit.MINUTES);
LOG.infov("EC2 terminated-instance pruning scheduled (1-minute interval, 1-hour retention)");
}
@PreDestroy
void stopTerminatedInstancePruner() {
if (terminatedInstancePruner != null) {
terminatedInstancePruner.shutdownNow();
}
}
private void pruneTerminatedInstancesSafely() {
try {
pruneTerminatedInstances();
} catch (RuntimeException e) {
LOG.warnv("Could not prune expired terminated EC2 instances: {0}", e.getMessage());
}
}
public List<Instance> describeInstanceStatus(String region, List<String> instanceIds,
boolean includeAllInstances) {
ensureDefaultResources(region);
if (config.services().ec2().mock()) {
instances.scan(k -> true).stream()
@@ -3299,11 +3427,41 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
instances.put(key(i.getRegion(), i.getInstanceId()), i);
});
}
return instances.scan(k -> true).stream()
List<Instance> matching = instances.scan(k -> true).stream()
.filter(i -> i.getRegion().equals(region))
.filter(i -> instanceIds.isEmpty() || instanceIds.contains(i.getInstanceId()))
.filter(i -> "running".equals(i.getState().getName()))
.filter(i -> includeAllInstances || "running".equals(i.getState().getName()))
.collect(Collectors.toList());
if (!config.services().ec2().mock()) {
for (Instance instance : matching) {
if (!"running".equals(instance.getState().getName())
|| "in-progress".equals(instance.getLaunchStatus())) {
continue;
}
Ec2ContainerManager.OperationResult result = containerManager.checkSshReadiness(instance);
String status = result.success() ? "ok" : "impaired";
String message = !result.success()
&& "Client.SSHUnavailable".equals(instance.getStateReasonCode())
&& instance.getStateReasonMessage() != null
? instance.getStateReasonMessage() : result.message();
if (!Objects.equals(status, instance.getSshStatus())
|| !Objects.equals(message, instance.getSshStatusMessage())) {
instance.setSshStatus(status);
instance.setSshStatusMessage(message);
if (result.success() && "failed".equals(instance.getLaunchStatus())
&& "Client.SSHUnavailable".equals(instance.getStateReasonCode())) {
instance.setLaunchStatus("ready");
instance.setStateReasonCode(null);
instance.setStateReasonMessage(null);
instance.setStateTransitionReason(null);
}
instance.getLaunchEvents().add(new InstanceLaunchEvent("ssh-status-check",
result.success() ? "completed" : "failed", Instant.now(), message));
persistInstance(instance);
}
}
}
return matching;
}
/**
@@ -4843,6 +5001,38 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
// ─── Key Pairs ─────────────────────────────────────────────────────────────
private void clearPersistedPrivateKeyMaterial() {
int cleared = 0;
if (keyPairs instanceof AccountAwareStorageBackend<?> rawAccountAware) {
@SuppressWarnings("unchecked")
AccountAwareStorageBackend<KeyPair> accountAware =
(AccountAwareStorageBackend<KeyPair>) rawAccountAware;
for (AccountAwareStorageBackend.AccountEntry<KeyPair> entry
: accountAware.scanAllAccountEntries(key -> true)) {
KeyPair keyPair = entry.value();
if (keyPair.getKeyMaterial() == null) {
continue;
}
keyPair.setKeyMaterial(null);
accountAware.putForAccount(entry.accountId(), entry.key(), keyPair);
cleared++;
}
} else {
for (String storageKey : keyPairs.keys()) {
KeyPair keyPair = keyPairs.get(storageKey).orElse(null);
if (keyPair == null || keyPair.getKeyMaterial() == null) {
continue;
}
keyPair.setKeyMaterial(null);
keyPairs.put(storageKey, keyPair);
cleared++;
}
}
if (cleared > 0) {
LOG.infov("Cleared private material from {0} persisted EC2 key pair record(s)", cleared);
}
}
public KeyPair createKeyPair(String region, String keyName) {
ensureDefaultResources(region);
requireKeyName(keyName);
@@ -4858,7 +5048,13 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
// Without storing it, a key pair created here could never authenticate anything.
kp.setPublicKey(generated.openSshPublicKey());
kp.setRegion(region);
keyPairs.put(key(region, keyPairId), kp);
KeyPair stored = new KeyPair();
stored.setKeyPairId(keyPairId);
stored.setKeyName(keyName);
stored.setKeyFingerprint(generated.fingerprint());
stored.setPublicKey(generated.openSshPublicKey());
stored.setRegion(region);
keyPairs.put(key(region, keyPairId), stored);
return kp;
}
@@ -4959,6 +5155,38 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
return containerManager.isContainerRunning(instance.getDockerContainerId());
}
public List<Map<String, Object>> diagnosticInstances() {
return instances.scan(key -> true).stream().map(instance -> {
boolean containerRunning = isInstanceContainerRunning(instance.getInstanceId());
String containerStatus = instance.getDockerContainerId() == null
? "unavailable" : containerRunning ? "running" : "stopped";
Map<String, Object> diagnostic = new LinkedHashMap<>();
diagnostic.put("instanceId", instance.getInstanceId());
diagnostic.put("sshHost", "localhost");
diagnostic.put("sshPort", instance.getSshHostPort());
diagnostic.put("containerId", instance.getDockerContainerId());
diagnostic.put("containerStatus", containerStatus);
diagnostic.put("state", instance.getState() == null ? null : instance.getState().getName());
diagnostic.put("launchStatus", instance.getLaunchStatus());
synchronized (instance) {
diagnostic.put("launchEvents", List.copyOf(instance.getLaunchEvents()));
}
diagnostic.put("selectedImageType", instance.getSelectedImageType());
diagnostic.put("selectedImageReference", instance.getSelectedImageReference());
diagnostic.put("sshUser", instance.getSshUser());
diagnostic.put("sshPreinstalled", instance.isSshPreinstalled());
diagnostic.put("initType", instance.getInitType());
diagnostic.put("sshServiceName", instance.getSshServiceName());
diagnostic.put("sshStatus", instance.getSshStatus());
diagnostic.put("sshStatusMessage", instance.getSshStatusMessage());
diagnostic.put("keyName", instance.getKeyName());
diagnostic.put("keyInjectionStatus", instance.getKeyInjectionStatus());
diagnostic.put("publishedPorts", instance.getPublishedPorts());
diagnostic.put("failure", instance.getStateReasonMessage());
return diagnostic;
}).toList();
}
private static void requireKeyName(String keyName) {
if (keyName == null || keyName.isBlank()) {
throw new AwsException("MissingParameter", "The request must contain the parameter KeyName", 400);
@@ -8326,6 +8554,12 @@ public class Ec2Service implements ContainerTeardown, ResourceProvider {
"Network interface '" + networkInterfaceId + "' is currently in use", 400);
}
networkInterfaces.delete(key(region, networkInterfaceId));
// Its addresses go back to the subnet, or every task or mount target that ever held one
// would leave it leased until the emulator restarts.
if (vpcNetworkManager != null && ni.getPrivateIpAddresses() != null) {
ni.getPrivateIpAddresses().forEach(address ->
vpcNetworkManager.releasePrivateIp(region, ni.getSubnetId(), address.getPrivateIpAddress()));
}
}
/**
@@ -1,15 +1,17 @@
package io.github.hectorvent.floci.services.ec2;
public record ResolvedAmiImage(String dockerImage, String guestRuntime, boolean cloudInit,
String dockerPlatform) {
public record ResolvedAmiImage(String dockerImage, String initType, boolean cloudInit,
String dockerPlatform, String fallbackDockerImage, String imageType,
String sshUser, boolean sshPreinstalled, String sshServiceName) {
public static final String DEFAULT_RUNTIME = "minimal";
public static final String SYSTEMD_RUNTIME = "systemd";
public static ResolvedAmiImage minimal(String dockerImage) {
return new ResolvedAmiImage(dockerImage, DEFAULT_RUNTIME, false, null);
return new ResolvedAmiImage(dockerImage, DEFAULT_RUNTIME, false, null, null,
"public", "root", false, "sshd");
}
public boolean systemd() {
return SYSTEMD_RUNTIME.equals(guestRuntime);
return SYSTEMD_RUNTIME.equals(initType);
}
}
@@ -48,8 +48,21 @@ public class Instance {
private String stateReasonCode;
private String stateReasonMessage;
private String region;
private String accountId;
private List<Tag> tags = new ArrayList<>();
private String launchStatus;
private List<InstanceLaunchEvent> launchEvents = new ArrayList<>();
private String selectedImageType;
private String selectedImageReference;
private String sshUser;
private boolean sshPreinstalled;
private String initType;
private String sshServiceName;
private String sshStatus;
private String sshStatusMessage;
private String keyInjectionStatus;
private String rootVolumeId;
// NOTE: disableApiStop and disableApiTermination are stored but ModifyInstanceAttribute
@@ -192,9 +205,50 @@ public class Instance {
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
public String getAccountId() { return accountId; }
public void setAccountId(String accountId) { this.accountId = accountId; }
public List<Tag> getTags() { return tags; }
public void setTags(List<Tag> tags) { this.tags = tags; }
public String getLaunchStatus() { return launchStatus; }
public void setLaunchStatus(String launchStatus) { this.launchStatus = launchStatus; }
public List<InstanceLaunchEvent> getLaunchEvents() {
if (launchEvents == null) {
launchEvents = new ArrayList<>();
}
return launchEvents;
}
public void setLaunchEvents(List<InstanceLaunchEvent> launchEvents) { this.launchEvents = launchEvents; }
public String getSelectedImageType() { return selectedImageType; }
public void setSelectedImageType(String selectedImageType) { this.selectedImageType = selectedImageType; }
public String getSelectedImageReference() { return selectedImageReference; }
public void setSelectedImageReference(String selectedImageReference) { this.selectedImageReference = selectedImageReference; }
public String getSshUser() { return sshUser; }
public void setSshUser(String sshUser) { this.sshUser = sshUser; }
public boolean isSshPreinstalled() { return sshPreinstalled; }
public void setSshPreinstalled(boolean sshPreinstalled) { this.sshPreinstalled = sshPreinstalled; }
public String getInitType() { return initType; }
public void setInitType(String initType) { this.initType = initType; }
public String getSshServiceName() { return sshServiceName; }
public void setSshServiceName(String sshServiceName) { this.sshServiceName = sshServiceName; }
public String getSshStatus() { return sshStatus; }
public void setSshStatus(String sshStatus) { this.sshStatus = sshStatus; }
public String getSshStatusMessage() { return sshStatusMessage; }
public void setSshStatusMessage(String sshStatusMessage) { this.sshStatusMessage = sshStatusMessage; }
public String getKeyInjectionStatus() { return keyInjectionStatus; }
public void setKeyInjectionStatus(String keyInjectionStatus) { this.keyInjectionStatus = keyInjectionStatus; }
public String getDockerContainerId() { return dockerContainerId; }
public void setDockerContainerId(String dockerContainerId) { this.dockerContainerId = dockerContainerId; }
@@ -0,0 +1,37 @@
package io.github.hectorvent.floci.services.ec2.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
public class InstanceLaunchEvent {
private String stage;
private String status;
private Instant timestamp;
private String message;
public InstanceLaunchEvent() {}
public InstanceLaunchEvent(String stage, String status, Instant timestamp, String message) {
this.stage = stage;
this.status = status;
this.timestamp = timestamp;
this.message = message;
}
public String getStage() { return stage; }
public void setStage(String stage) { this.stage = stage; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getTimestamp() { return timestamp; }
public void setTimestamp(Instant timestamp) { this.timestamp = timestamp; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}

Some files were not shown because too many files have changed in this diff Show More