@rc312/effect-aws-imds 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/dist/aws-imds.d.ts +58 -0
- package/dist/aws-imds.d.ts.map +1 -0
- package/dist/aws-imds.js +65 -0
- package/dist/aws-imds.js.map +1 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +37 -0
- package/dist/config.js.map +1 -0
- package/dist/identity-document.d.ts +33 -0
- package/dist/identity-document.d.ts.map +1 -0
- package/dist/identity-document.js +58 -0
- package/dist/identity-document.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/instance-identity.d.ts +43 -0
- package/dist/instance-identity.d.ts.map +1 -0
- package/dist/instance-identity.js +38 -0
- package/dist/instance-identity.js.map +1 -0
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +15 -0
- package/dist/internal.js.map +1 -0
- package/dist/layer.d.ts +25 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +26 -0
- package/dist/layer.js.map +1 -0
- package/dist/metadata-error.d.ts +22 -0
- package/dist/metadata-error.d.ts.map +1 -0
- package/dist/metadata-error.js +19 -0
- package/dist/metadata-error.js.map +1 -0
- package/dist/token-error.d.ts +19 -0
- package/dist/token-error.d.ts.map +1 -0
- package/dist/token-error.js +16 -0
- package/dist/token-error.js.map +1 -0
- package/dist/token.d.ts +27 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +53 -0
- package/dist/token.js.map +1 -0
- package/dist/transport.d.ts +26 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +46 -0
- package/dist/transport.js.map +1 -0
- package/package.json +42 -4
- package/src/aws-imds.ts +118 -0
- package/src/config.ts +96 -0
- package/src/identity-document.ts +86 -0
- package/src/index.ts +66 -0
- package/src/instance-identity.ts +63 -0
- package/src/internal.ts +21 -0
- package/src/layer.ts +36 -0
- package/src/metadata-error.ts +21 -0
- package/src/token-error.ts +18 -0
- package/src/token.ts +114 -0
- package/src/transport.ts +101 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Config, Context, Duration, Effect, Layer, Schedule } from "effect";
|
|
2
|
+
|
|
3
|
+
const RETRY_BASE_DELAY = Duration.millis(50);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The default retry policy: jittered exponential backoff from 50 ms, bounded
|
|
7
|
+
* to `times` retries after the first attempt.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
const retryPolicy = (times: number) =>
|
|
12
|
+
Schedule.exponential(RETRY_BASE_DELAY).pipe(
|
|
13
|
+
Schedule.jittered,
|
|
14
|
+
Schedule.upTo({ times })
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolved settings read by every IMDS layer.
|
|
19
|
+
*
|
|
20
|
+
* @since 0.1.0
|
|
21
|
+
*/
|
|
22
|
+
interface AwsImdsSettings {
|
|
23
|
+
/** Base URL of the metadata service. */
|
|
24
|
+
readonly endpoint: string;
|
|
25
|
+
/**
|
|
26
|
+
* Requested session token lifetime. The value sent to IMDS is rounded down
|
|
27
|
+
* to whole seconds and clamped to 1 second through 6 hours.
|
|
28
|
+
*/
|
|
29
|
+
readonly tokenTtl: Duration.Duration;
|
|
30
|
+
/** Timeout for each individual HTTP attempt. */
|
|
31
|
+
readonly attemptTimeout: Duration.Duration;
|
|
32
|
+
/**
|
|
33
|
+
* Policy for retrying timeouts, transport errors, and transient statuses.
|
|
34
|
+
* It is the only bound on retries, so it must stop on its own, for example
|
|
35
|
+
* with `Schedule.upTo`.
|
|
36
|
+
*/
|
|
37
|
+
readonly retrySchedule: Schedule.Schedule<unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Partial settings for `AwsImdsConfig.layer` and `AwsImdsConfig.layerConfig`.
|
|
42
|
+
* Omitted fields keep their defaults.
|
|
43
|
+
*
|
|
44
|
+
* @since 0.1.0
|
|
45
|
+
*/
|
|
46
|
+
interface AwsImdsOptions {
|
|
47
|
+
/** Defaults to `http://169.254.169.254`; use `http://[fd00:ec2::254]` on IPv6-only instances. */
|
|
48
|
+
readonly endpoint?: string | undefined;
|
|
49
|
+
/** Defaults to 6 hours. */
|
|
50
|
+
readonly tokenTtl?: Duration.Input | undefined;
|
|
51
|
+
/** Defaults to 1 second. */
|
|
52
|
+
readonly attemptTimeout?: Duration.Input | undefined;
|
|
53
|
+
/** Defaults to `retryPolicy(2)`. Must be bounded, for example with `Schedule.upTo`. */
|
|
54
|
+
readonly retrySchedule?: Schedule.Schedule<unknown> | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const defaults: AwsImdsSettings = {
|
|
58
|
+
attemptTimeout: Duration.seconds(1),
|
|
59
|
+
endpoint: "http://169.254.169.254",
|
|
60
|
+
retrySchedule: retryPolicy(2),
|
|
61
|
+
tokenTtl: Duration.hours(6),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const resolve = (options: AwsImdsOptions): AwsImdsSettings => ({
|
|
65
|
+
attemptTimeout: Duration.fromInputUnsafe(
|
|
66
|
+
options.attemptTimeout ?? defaults.attemptTimeout
|
|
67
|
+
),
|
|
68
|
+
endpoint: options.endpoint ?? defaults.endpoint,
|
|
69
|
+
retrySchedule: options.retrySchedule ?? defaults.retrySchedule,
|
|
70
|
+
tokenTtl: Duration.fromInputUnsafe(options.tokenTtl ?? defaults.tokenTtl),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Settings for the IMDS layers, with defaults when nothing provides them.
|
|
75
|
+
*
|
|
76
|
+
* Provide an override to the layers with `Layer.provide`; merging it beside
|
|
77
|
+
* them has no effect on how they are built.
|
|
78
|
+
*
|
|
79
|
+
* @since 0.1.0
|
|
80
|
+
*/
|
|
81
|
+
class AwsImdsConfig extends Context.Reference<AwsImdsSettings>(
|
|
82
|
+
"@rc312/effect-aws-imds/AwsImdsConfig",
|
|
83
|
+
{ defaultValue: () => defaults }
|
|
84
|
+
) {
|
|
85
|
+
/** Overrides the given settings and keeps defaults for the rest. */
|
|
86
|
+
static readonly layer = (options: AwsImdsOptions) =>
|
|
87
|
+
Layer.sync(AwsImdsConfig, () => resolve(options));
|
|
88
|
+
|
|
89
|
+
/** Reads settings from `Config`, field by field or as one `Config`. */
|
|
90
|
+
static readonly layerConfig = (config: Config.Wrap<AwsImdsOptions>) =>
|
|
91
|
+
Layer.effect(AwsImdsConfig, Effect.map(Config.unwrap(config), resolve));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { AwsImdsConfig, retryPolicy };
|
|
95
|
+
|
|
96
|
+
export type { AwsImdsOptions, AwsImdsSettings };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const optionalNullable = <S extends Schema.Top>(schema: S) =>
|
|
4
|
+
Schema.NullOr(schema).pipe(
|
|
5
|
+
Schema.optional,
|
|
6
|
+
Schema.withDecodingDefault(Effect.succeed(null))
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Schema for the EC2 instance identity document served at
|
|
11
|
+
* `/latest/dynamic/instance-identity/document`.
|
|
12
|
+
*
|
|
13
|
+
* Unknown keys are ignored so that fields AWS adds later do not break decoding.
|
|
14
|
+
*
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*/
|
|
17
|
+
// oxlint-disable-next-line no-redeclare -- The decoded type below intentionally shares the schema's name.
|
|
18
|
+
export const IdentityDocument = Schema.Struct({
|
|
19
|
+
accountId: Schema.String.check(Schema.isPattern(/^\d{12}$/u)).annotateKey({
|
|
20
|
+
description: "The ID of the AWS account that launched the instance.",
|
|
21
|
+
}),
|
|
22
|
+
architecture: Schema.Literals(["i386", "x86_64", "arm64"]).annotateKey({
|
|
23
|
+
description: "The architecture of the AMI used to launch the instance.",
|
|
24
|
+
}),
|
|
25
|
+
availabilityZone: Schema.NonEmptyString.annotateKey({
|
|
26
|
+
description: "The Availability Zone in which the instance is running.",
|
|
27
|
+
}),
|
|
28
|
+
billingProducts: optionalNullable(Schema.Array(Schema.String)).annotateKey({
|
|
29
|
+
description: "The billing products of the instance.",
|
|
30
|
+
}),
|
|
31
|
+
devpayProductCodes: optionalNullable(Schema.Array(Schema.String)).annotateKey(
|
|
32
|
+
{ description: "Deprecated AWS DevPay product codes of the AMI." }
|
|
33
|
+
),
|
|
34
|
+
imageId: Schema.String.check(
|
|
35
|
+
Schema.isPattern(/^ami-[0-9a-f]+$/iu)
|
|
36
|
+
).annotateKey({
|
|
37
|
+
description: "The ID of the AMI used to launch the instance.",
|
|
38
|
+
}),
|
|
39
|
+
instanceId: Schema.String.check(
|
|
40
|
+
Schema.isPattern(/^i-[0-9a-f]+$/iu)
|
|
41
|
+
).annotateKey({ description: "The ID of the instance." }),
|
|
42
|
+
instanceType: Schema.NonEmptyString.annotateKey({
|
|
43
|
+
description: "The instance type of the instance.",
|
|
44
|
+
}),
|
|
45
|
+
kernelId: optionalNullable(Schema.String).annotateKey({
|
|
46
|
+
description: "The ID of the kernel associated with the instance.",
|
|
47
|
+
}),
|
|
48
|
+
marketplaceProductCodes: optionalNullable(
|
|
49
|
+
Schema.Array(Schema.String)
|
|
50
|
+
).annotateKey({
|
|
51
|
+
description: "The AWS Marketplace product codes of the AMI.",
|
|
52
|
+
}),
|
|
53
|
+
pendingTime: Schema.DateFromString.annotateKey({
|
|
54
|
+
description: "The date and time that the instance was launched.",
|
|
55
|
+
}),
|
|
56
|
+
privateIp: Schema.String.check(
|
|
57
|
+
Schema.isNonEmpty(),
|
|
58
|
+
Schema.isTrimmed()
|
|
59
|
+
).annotateKey({
|
|
60
|
+
description:
|
|
61
|
+
"The private IPv4 address, or IPv6 address on IPv6-only instances.",
|
|
62
|
+
}),
|
|
63
|
+
ramdiskId: optionalNullable(Schema.String).annotateKey({
|
|
64
|
+
description: "The ID of the RAM disk associated with the instance.",
|
|
65
|
+
}),
|
|
66
|
+
region: Schema.String.check(
|
|
67
|
+
Schema.isPattern(/^[a-z]{2,}(?:-[a-z]+)+-\d+$/u)
|
|
68
|
+
).annotateKey({
|
|
69
|
+
description: "The Region in which the instance is running.",
|
|
70
|
+
}),
|
|
71
|
+
version: Schema.String.check(
|
|
72
|
+
Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/u)
|
|
73
|
+
).annotateKey({
|
|
74
|
+
description: "The version of the identity document format.",
|
|
75
|
+
}),
|
|
76
|
+
}).annotate({
|
|
77
|
+
identifier: "IdentityDocument",
|
|
78
|
+
title: "EC2 Instance Identity Document",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A decoded EC2 instance identity document.
|
|
83
|
+
*
|
|
84
|
+
* @since 0.1.0
|
|
85
|
+
*/
|
|
86
|
+
export type IdentityDocument = typeof IdentityDocument.Type;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect-native client for the AWS EC2 Instance Metadata Service (IMDS).
|
|
3
|
+
*
|
|
4
|
+
* The client speaks IMDSv2 only. Session tokens are fetched with `PUT
|
|
5
|
+
* /latest/api/token`, cached for slightly less than their TTL, and refreshed
|
|
6
|
+
* once when IMDS answers `401`. Every attempt has its own timeout, and transient
|
|
7
|
+
* failures are retried with jittered exponential backoff.
|
|
8
|
+
*
|
|
9
|
+
* The instance identity document is decoded against `IdentityDocument` and
|
|
10
|
+
* memoized after the first success. Failures are never cached, so a caller can
|
|
11
|
+
* try again later.
|
|
12
|
+
*
|
|
13
|
+
* Outside EC2 (local development, or a container beyond the IMDS hop limit)
|
|
14
|
+
* operations fail with a typed `AwsImdsError` rather than returning `undefined`.
|
|
15
|
+
* Apply `Effect.option` at the call site when the value is genuinely optional.
|
|
16
|
+
* With the defaults, an endpoint that never answers fails after about 3.2
|
|
17
|
+
* seconds (three 1 second attempts plus backoff); a refused connection fails
|
|
18
|
+
* within a fraction of a second.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { Effect } from "effect"
|
|
23
|
+
* import * as AwsImds from "@rc312/effect-aws-imds"
|
|
24
|
+
*
|
|
25
|
+
* const program = Effect.gen(function* () {
|
|
26
|
+
* // Yield the Effect to obtain the decoded, memoized document.
|
|
27
|
+
* const document = yield* AwsImds.identityDocument
|
|
28
|
+
* // document.instanceType is already available without another request.
|
|
29
|
+
* // This extra request demonstrates uncached reads with get(path).
|
|
30
|
+
* const instanceType = yield* AwsImds.get("/latest/meta-data/instance-type")
|
|
31
|
+
* return { region: document.region, instanceType }
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* const runnable = program.pipe(Effect.provide(AwsImds.layerFetch))
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @since 0.1.0
|
|
38
|
+
*/
|
|
39
|
+
export {
|
|
40
|
+
AwsImds,
|
|
41
|
+
type AwsImdsError,
|
|
42
|
+
get,
|
|
43
|
+
getSchema,
|
|
44
|
+
optional,
|
|
45
|
+
} from "./aws-imds.ts";
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
AwsImdsConfig,
|
|
49
|
+
type AwsImdsOptions,
|
|
50
|
+
type AwsImdsSettings,
|
|
51
|
+
retryPolicy,
|
|
52
|
+
} from "./config.ts";
|
|
53
|
+
|
|
54
|
+
export { IdentityDocument } from "./identity-document.ts";
|
|
55
|
+
|
|
56
|
+
export { identityDocument, InstanceIdentity } from "./instance-identity.ts";
|
|
57
|
+
|
|
58
|
+
export { clearCache, layer, layerFetch } from "./layer.ts";
|
|
59
|
+
|
|
60
|
+
export { AwsImdsToken } from "./token.ts";
|
|
61
|
+
|
|
62
|
+
export { imdsTransport, withSessionToken } from "./transport.ts";
|
|
63
|
+
|
|
64
|
+
export { AwsImdsMetadataError } from "./metadata-error.ts";
|
|
65
|
+
|
|
66
|
+
export { AwsImdsTokenError } from "./token-error.ts";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { AwsImds, decodeAt } from "./aws-imds.ts";
|
|
4
|
+
import type { AwsImdsError } from "./aws-imds.ts";
|
|
5
|
+
import { IdentityDocument } from "./identity-document.ts";
|
|
6
|
+
import { cacheOnSuccess } from "./internal.ts";
|
|
7
|
+
|
|
8
|
+
const IDENTITY_DOCUMENT_PATH = "/latest/dynamic/instance-identity/document";
|
|
9
|
+
|
|
10
|
+
const decodeDocument = decodeAt(
|
|
11
|
+
IDENTITY_DOCUMENT_PATH,
|
|
12
|
+
Schema.fromJsonString(IdentityDocument)
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The decoded instance identity document, memoized after the first success.
|
|
17
|
+
* Failures are not cached. Provide a fixed document with `Layer.succeed` to
|
|
18
|
+
* test code that depends on it without any HTTP.
|
|
19
|
+
*
|
|
20
|
+
* @since 0.1.0
|
|
21
|
+
*/
|
|
22
|
+
class InstanceIdentity extends Context.Service<
|
|
23
|
+
InstanceIdentity,
|
|
24
|
+
{
|
|
25
|
+
readonly document: Effect.Effect<IdentityDocument, AwsImdsError>;
|
|
26
|
+
readonly invalidate: Effect.Effect<void>;
|
|
27
|
+
}
|
|
28
|
+
>()("@rc312/effect-aws-imds/InstanceIdentity") {
|
|
29
|
+
/** Reads the document through `AwsImds`. */
|
|
30
|
+
static readonly layer: Layer.Layer<InstanceIdentity, never, AwsImds> =
|
|
31
|
+
Layer.effect(
|
|
32
|
+
InstanceIdentity,
|
|
33
|
+
Effect.gen(function* make() {
|
|
34
|
+
const imds = yield* AwsImds;
|
|
35
|
+
|
|
36
|
+
const resolve = imds
|
|
37
|
+
.get(IDENTITY_DOCUMENT_PATH)
|
|
38
|
+
.pipe(
|
|
39
|
+
Effect.flatMap(decodeDocument),
|
|
40
|
+
Effect.withSpan("InstanceIdentity.resolve")
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const documents = yield* Cache.makeWith(() => resolve, {
|
|
44
|
+
capacity: 1,
|
|
45
|
+
timeToLive: cacheOnSuccess(Duration.infinity),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return InstanceIdentity.of({
|
|
49
|
+
document: Cache.get(documents, IDENTITY_DOCUMENT_PATH),
|
|
50
|
+
invalidate: Cache.invalidate(documents, IDENTITY_DOCUMENT_PATH),
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The memoized, decoded instance identity document.
|
|
58
|
+
*
|
|
59
|
+
* @since 0.1.0
|
|
60
|
+
*/
|
|
61
|
+
const identityDocument = InstanceIdentity.use((identity) => identity.document);
|
|
62
|
+
|
|
63
|
+
export { identityDocument, InstanceIdentity };
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Duration, Exit } from "effect";
|
|
2
|
+
import { HttpClientError } from "effect/http";
|
|
3
|
+
|
|
4
|
+
// Keep only the message and status: client errors retain the request headers.
|
|
5
|
+
const failureDetails = (cause: Error) => {
|
|
6
|
+
const status = HttpClientError.isHttpClientError(cause)
|
|
7
|
+
? cause.response?.status
|
|
8
|
+
: undefined;
|
|
9
|
+
|
|
10
|
+
return status === undefined
|
|
11
|
+
? { message: cause.message }
|
|
12
|
+
: { message: cause.message, status };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// `Effect.cachedWithTTL` would also cache failures; IMDS failures are transient.
|
|
16
|
+
const cacheOnSuccess =
|
|
17
|
+
(ttl: Duration.Duration) =>
|
|
18
|
+
<A, E>(exit: Exit.Exit<A, E>) =>
|
|
19
|
+
Exit.isSuccess(exit) ? ttl : Duration.zero;
|
|
20
|
+
|
|
21
|
+
export { cacheOnSuccess, failureDetails };
|
package/src/layer.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { FetchHttpClient } from "effect/http";
|
|
3
|
+
|
|
4
|
+
import { AwsImds } from "./aws-imds.ts";
|
|
5
|
+
import { InstanceIdentity } from "./instance-identity.ts";
|
|
6
|
+
import { AwsImdsToken } from "./token.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Provides `InstanceIdentity`, `AwsImds`, and `AwsImdsToken` from the
|
|
10
|
+
* `HttpClient` in the environment. Settings come from `AwsImdsConfig`.
|
|
11
|
+
*
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
*/
|
|
14
|
+
const layer = InstanceIdentity.layer.pipe(
|
|
15
|
+
Layer.provideMerge(AwsImds.layer),
|
|
16
|
+
Layer.provideMerge(AwsImdsToken.layer)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `layer` backed by the platform `fetch` through `FetchHttpClient`.
|
|
21
|
+
*
|
|
22
|
+
* @since 0.1.0
|
|
23
|
+
*/
|
|
24
|
+
const layerFetch = layer.pipe(Layer.provide(FetchHttpClient.layer));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Discards the cached session token and identity document.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
*/
|
|
31
|
+
const clearCache = Effect.andThen(
|
|
32
|
+
AwsImdsToken.use((token) => token.clear),
|
|
33
|
+
InstanceIdentity.use((identity) => identity.invalidate)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export { clearCache, layer, layerFetch };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Failure to read or decode the metadata at `path`.
|
|
5
|
+
*
|
|
6
|
+
* `status` is the HTTP status when IMDS answered with a non-2xx response, for
|
|
7
|
+
* example `404` for a path that does not exist on this instance. Transport,
|
|
8
|
+
* timeout, and decoding failures have no `status`. The underlying request is
|
|
9
|
+
* not retained because its headers carry the session token.
|
|
10
|
+
*
|
|
11
|
+
* @since 0.1.0
|
|
12
|
+
*/
|
|
13
|
+
// oxlint-disable-next-line unicorn/throw-new-error -- TaggedError is a class factory.
|
|
14
|
+
export class AwsImdsMetadataError extends Schema.TaggedError<
|
|
15
|
+
AwsImdsMetadataError,
|
|
16
|
+
{ readonly brand: unique symbol }
|
|
17
|
+
>()("AwsImdsMetadataError", {
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
path: Schema.String,
|
|
20
|
+
status: Schema.optional(Schema.Int),
|
|
21
|
+
}) {}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Failure to obtain an IMDSv2 session token.
|
|
5
|
+
*
|
|
6
|
+
* `status` is the HTTP status when IMDS answered the token request with a
|
|
7
|
+
* non-2xx response. Transport, timeout, and decoding failures have no `status`.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable-next-line unicorn/throw-new-error -- TaggedError is a class factory.
|
|
12
|
+
export class AwsImdsTokenError extends Schema.TaggedError<
|
|
13
|
+
AwsImdsTokenError,
|
|
14
|
+
{ readonly brand: unique symbol }
|
|
15
|
+
>()("AwsImdsTokenError", {
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
status: Schema.optional(Schema.Int),
|
|
18
|
+
}) {}
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Cache,
|
|
3
|
+
Context,
|
|
4
|
+
Duration,
|
|
5
|
+
Effect,
|
|
6
|
+
Layer,
|
|
7
|
+
Option,
|
|
8
|
+
Redacted,
|
|
9
|
+
Schema,
|
|
10
|
+
} from "effect";
|
|
11
|
+
import { HttpClient } from "effect/http";
|
|
12
|
+
|
|
13
|
+
import { AwsImdsConfig } from "./config.ts";
|
|
14
|
+
import { cacheOnSuccess, failureDetails } from "./internal.ts";
|
|
15
|
+
import { AwsImdsTokenError } from "./token-error.ts";
|
|
16
|
+
import { imdsTransport, TOKEN_HEADER } from "./transport.ts";
|
|
17
|
+
|
|
18
|
+
const TOKEN_PATH = "/latest/api/token";
|
|
19
|
+
const TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds";
|
|
20
|
+
const MIN_TTL_SECONDS = 1;
|
|
21
|
+
const MAX_TTL_SECONDS = 21_600;
|
|
22
|
+
const REFRESH_MARGIN = Duration.seconds(30);
|
|
23
|
+
|
|
24
|
+
const decodeToken = Schema.decodeEffect(
|
|
25
|
+
Schema.NonEmptyString.annotate({ description: "IMDSv2 session token" })
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const ttlSeconds = (ttl: Duration.Duration) =>
|
|
29
|
+
Math.min(
|
|
30
|
+
MAX_TTL_SECONDS,
|
|
31
|
+
Math.max(MIN_TTL_SECONDS, Math.floor(Duration.toSeconds(ttl)))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Refresh 30 seconds early, or halfway through a short-lived token.
|
|
35
|
+
const refreshAfter = (ttl: Duration.Duration) =>
|
|
36
|
+
Duration.max(
|
|
37
|
+
Duration.subtract(ttl, REFRESH_MARGIN),
|
|
38
|
+
Duration.divideUnsafe(ttl, 2)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const toTokenError = (cause: Error) =>
|
|
42
|
+
new AwsImdsTokenError(failureDetails(cause));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* IMDSv2 session tokens.
|
|
46
|
+
*
|
|
47
|
+
* - `get` returns the cached token, requesting one with `PUT /latest/api/token`
|
|
48
|
+
* when none is cached or the cached one is close to expiry.
|
|
49
|
+
* - `invalidate(token)` discards `token` if it is still the cached one, so
|
|
50
|
+
* concurrent reads that were all rejected trigger a single refresh.
|
|
51
|
+
* - `clear` discards the cached token unconditionally.
|
|
52
|
+
*
|
|
53
|
+
* Provide your own implementation with `Layer.succeed` to use a fixed token.
|
|
54
|
+
*
|
|
55
|
+
* @since 0.1.0
|
|
56
|
+
*/
|
|
57
|
+
class AwsImdsToken extends Context.Service<
|
|
58
|
+
AwsImdsToken,
|
|
59
|
+
{
|
|
60
|
+
readonly get: Effect.Effect<Redacted.Redacted<string>, AwsImdsTokenError>;
|
|
61
|
+
readonly invalidate: (
|
|
62
|
+
rejected: Redacted.Redacted<string>
|
|
63
|
+
) => Effect.Effect<void>;
|
|
64
|
+
readonly clear: Effect.Effect<void>;
|
|
65
|
+
}
|
|
66
|
+
>()("@rc312/effect-aws-imds/AwsImdsToken") {
|
|
67
|
+
/** Requests tokens through the `HttpClient`, configured by `AwsImdsConfig`. */
|
|
68
|
+
static readonly layer: Layer.Layer<
|
|
69
|
+
AwsImdsToken,
|
|
70
|
+
never,
|
|
71
|
+
HttpClient.HttpClient
|
|
72
|
+
> = Layer.effect(
|
|
73
|
+
AwsImdsToken,
|
|
74
|
+
Effect.gen(function* make() {
|
|
75
|
+
const settings = yield* AwsImdsConfig;
|
|
76
|
+
const seconds = ttlSeconds(settings.tokenTtl);
|
|
77
|
+
|
|
78
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
79
|
+
imdsTransport(settings)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const fetchToken = client
|
|
83
|
+
.put(TOKEN_PATH, { headers: { [TOKEN_TTL_HEADER]: String(seconds) } })
|
|
84
|
+
.pipe(
|
|
85
|
+
Effect.flatMap((response) => response.text),
|
|
86
|
+
Effect.flatMap((text) => decodeToken(text)),
|
|
87
|
+
Effect.map((token) => Redacted.make(token, { label: TOKEN_HEADER })),
|
|
88
|
+
Effect.mapError(toTokenError),
|
|
89
|
+
Effect.withSpan("AwsImdsToken.fetch")
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const tokens = yield* Cache.makeWith(() => fetchToken, {
|
|
93
|
+
capacity: 1,
|
|
94
|
+
timeToLive: cacheOnSuccess(refreshAfter(Duration.seconds(seconds))),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const current = Cache.get(tokens, TOKEN_PATH);
|
|
98
|
+
const clear = Cache.invalidate(tokens, TOKEN_PATH);
|
|
99
|
+
|
|
100
|
+
return AwsImdsToken.of({
|
|
101
|
+
clear,
|
|
102
|
+
get: current,
|
|
103
|
+
invalidate: (rejected) =>
|
|
104
|
+
Cache.getSuccess(tokens, TOKEN_PATH).pipe(
|
|
105
|
+
Effect.flatMap((cached) =>
|
|
106
|
+
Option.contains(cached, rejected) ? clear : Effect.void
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { AwsImdsToken };
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
Headers,
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpClientError,
|
|
6
|
+
HttpClientRequest,
|
|
7
|
+
} from "effect/http";
|
|
8
|
+
|
|
9
|
+
import type { AwsImdsSettings } from "./config.ts";
|
|
10
|
+
import type { AwsImdsToken } from "./token.ts";
|
|
11
|
+
|
|
12
|
+
const TOKEN_HEADER = "x-aws-ec2-metadata-token";
|
|
13
|
+
const UNAUTHORIZED = 401;
|
|
14
|
+
|
|
15
|
+
// Redacts the session token from client spans that capture request headers.
|
|
16
|
+
const redactTokenHeader = Effect.updateService(
|
|
17
|
+
Headers.CurrentRedactedNames,
|
|
18
|
+
(names) => [...names, TOKEN_HEADER]
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* IMDS transport policy for any `HttpClient`: the endpoint as base URL, a
|
|
23
|
+
* timeout on each attempt including its text body, non-2xx statuses as errors,
|
|
24
|
+
* and transient retries on `settings.retrySchedule`.
|
|
25
|
+
*
|
|
26
|
+
* @since 0.1.0
|
|
27
|
+
*/
|
|
28
|
+
const imdsTransport =
|
|
29
|
+
(settings: AwsImdsSettings) =>
|
|
30
|
+
<E, R>(client: HttpClient.HttpClient.With<E, R>) =>
|
|
31
|
+
client.pipe(
|
|
32
|
+
HttpClient.mapRequest(HttpClientRequest.prependUrl(settings.endpoint)),
|
|
33
|
+
HttpClient.filterStatusOk,
|
|
34
|
+
// HTTP responses memoize their text, so callers can read it again after
|
|
35
|
+
// the complete download has succeeded within the attempt's deadline.
|
|
36
|
+
HttpClient.tap((response) => response.text),
|
|
37
|
+
HttpClient.transformResponse(Effect.timeout(settings.attemptTimeout)),
|
|
38
|
+
HttpClient.retryTransient({
|
|
39
|
+
schedule: settings.retrySchedule,
|
|
40
|
+
// Reading the text body can fail after headers have already arrived.
|
|
41
|
+
// Schema/JSON decoding happens outside this transport policy.
|
|
42
|
+
while: (error) =>
|
|
43
|
+
HttpClientError.isHttpClientError(error) &&
|
|
44
|
+
error.reason instanceof HttpClientError.DecodeError,
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const isUnauthorized = <E>(error: E) =>
|
|
49
|
+
HttpClientError.isHttpClientError(error) &&
|
|
50
|
+
error.response?.status === UNAUTHORIZED;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wraps a client so every request carries a session token. The token is
|
|
54
|
+
* resolved before the request, outside its timeout and transient retries; a
|
|
55
|
+
* `401` invalidates that token and the request runs once more with a new one.
|
|
56
|
+
*
|
|
57
|
+
* Apply it after `imdsTransport`: the transport's timeout and retries then
|
|
58
|
+
* cover each metadata request, while time spent obtaining the token does not
|
|
59
|
+
* count against them.
|
|
60
|
+
*
|
|
61
|
+
* @since 0.1.0
|
|
62
|
+
*/
|
|
63
|
+
const withSessionToken =
|
|
64
|
+
(token: AwsImdsToken["Service"]) =>
|
|
65
|
+
<E, R>(client: HttpClient.HttpClient.With<E, R>) => {
|
|
66
|
+
const attempt = (request: HttpClientRequest.HttpClientRequest) =>
|
|
67
|
+
token.get.pipe(
|
|
68
|
+
Effect.flatMap((sessionToken) =>
|
|
69
|
+
client
|
|
70
|
+
.execute(
|
|
71
|
+
HttpClientRequest.setHeader(
|
|
72
|
+
request,
|
|
73
|
+
TOKEN_HEADER,
|
|
74
|
+
Redacted.value(sessionToken)
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
.pipe(
|
|
78
|
+
redactTokenHeader,
|
|
79
|
+
Effect.tapError((failure) =>
|
|
80
|
+
isUnauthorized(failure)
|
|
81
|
+
? token.invalidate(sessionToken)
|
|
82
|
+
: Effect.void
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return HttpClient.makeWith(
|
|
89
|
+
(request: Effect.Effect<HttpClientRequest.HttpClientRequest>) =>
|
|
90
|
+
request.pipe(
|
|
91
|
+
Effect.flatMap((prepared) =>
|
|
92
|
+
attempt(prepared).pipe(
|
|
93
|
+
Effect.retry({ times: 1, while: isUnauthorized })
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
),
|
|
97
|
+
Effect.succeed
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export { imdsTransport, TOKEN_HEADER, withSessionToken };
|