@saws/redis-service 2.0.0-beta.3

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/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@saws/redis-service",
3
+ "version": "2.0.0-beta.3",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "module",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@saws/core": "2.0.0-beta.3",
17
+ "@saws/docker-service": "2.0.0-beta.3"
18
+ }
19
+ }
@@ -0,0 +1,181 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { SecretsManager, type ServiceEnvironmentTarget } from "@saws/core";
5
+ import type { Outputs } from "@saws/core/utils/stage-outputs";
6
+ import { DockerService, type DockerServiceConfig } from "@saws/docker-service";
7
+
8
+ export type RedisConnectionTarget = "container" | "host";
9
+
10
+ export type RedisConnectionInfo = {
11
+ host: string;
12
+ port: string;
13
+ password: string;
14
+ url: string;
15
+ };
16
+
17
+ export interface RedisServiceConfig extends Omit<
18
+ DockerServiceConfig,
19
+ "image" | "dockerfile" | "buildContext" | "volumes" | "ports" | "command"
20
+ > {
21
+ image?: string;
22
+ /** Host port to expose Redis on. Local dev defaults to 6379; deploys stay private unless set. */
23
+ port?: number;
24
+ /** Explicit Redis password. Omit to persist a generated password in SAWS secrets. */
25
+ password?: string;
26
+ /** Override the Docker volume name. By default SAWS derives a stable stage/service volume. */
27
+ volume?: string;
28
+ dataDirectory?: string;
29
+ }
30
+
31
+ export class RedisService extends DockerService {
32
+ readonly port?: number;
33
+ readonly password?: string;
34
+ readonly volume?: string;
35
+ readonly dataDirectory: string;
36
+ protected override readonly serviceType = "redis";
37
+
38
+ constructor(config: RedisServiceConfig) {
39
+ super({
40
+ ...config,
41
+ image: config.image ?? "redis:8",
42
+ healthCheck: config.healthCheck ?? {
43
+ command: 'redis-cli -a "$REDIS_PASSWORD" ping',
44
+ interval: "10s",
45
+ timeout: "5s",
46
+ retries: 5,
47
+ startPeriod: "10s",
48
+ },
49
+ });
50
+
51
+ this.port = config.port;
52
+ this.password = config.password;
53
+ this.volume = config.volume;
54
+ this.dataDirectory = config.dataDirectory ?? "/data";
55
+ }
56
+
57
+ override async getEnvironmentVariables(
58
+ stage: string,
59
+ target: ServiceEnvironmentTarget = "container",
60
+ ): Promise<Record<string, string>> {
61
+ const connection = await this.getConnectionInfo(stage, target);
62
+ const prefix = this.environmentVariablePrefix;
63
+
64
+ return {
65
+ [`${prefix}_REDIS_HOST`]: connection.host,
66
+ [`${prefix}_REDIS_PORT`]: connection.port,
67
+ [`${prefix}_REDIS_PASSWORD`]: connection.password,
68
+ [`${prefix}_REDIS_URL`]: connection.url,
69
+ };
70
+ }
71
+
72
+ async getConnectionInfo(
73
+ stage: string,
74
+ target: RedisConnectionTarget = "host",
75
+ ): Promise<RedisConnectionInfo> {
76
+ const password = this.password ?? (await this.getOrCreatePassword(stage));
77
+ const host = target === "container" ? this.getContainerName(stage) : this.getHost(stage);
78
+ const port = target === "container" ? "6379" : String(this.getHostPort(stage));
79
+
80
+ return {
81
+ host,
82
+ port,
83
+ password,
84
+ url: this.toRedisUrl(host, port, password),
85
+ };
86
+ }
87
+
88
+ protected override async getContainerEnvironment(stage: string): Promise<Record<string, string>> {
89
+ return {
90
+ ...(await super.getContainerEnvironment(stage)),
91
+ REDIS_PASSWORD: this.password ?? (await this.getOrCreatePassword(stage)),
92
+ };
93
+ }
94
+
95
+ protected override async getDockerRunConfig(stage: string, deploy: boolean) {
96
+ const config = await super.getDockerRunConfig(stage, deploy);
97
+ const password = this.password ?? (await this.getOrCreatePassword(stage));
98
+
99
+ return {
100
+ ...config,
101
+ volumes: [`${this.getVolumeName(stage)}:${this.dataDirectory}`],
102
+ ports: deploy && this.port == null ? [] : [`${this.getHostPort(stage)}:6379`],
103
+ command: [
104
+ "redis-server",
105
+ "--requirepass",
106
+ password,
107
+ "--save",
108
+ "60",
109
+ "1",
110
+ "--loglevel",
111
+ "warning",
112
+ ],
113
+ };
114
+ }
115
+
116
+ protected override async onContainerStarted(stage: string) {
117
+ await this.setOutputs(this.toOutputs(await this.getConnectionInfo(stage, "host")), stage);
118
+ }
119
+
120
+ private get environmentVariablePrefix() {
121
+ return this.name.replace(/[^a-zA-Z\d]/g, "_").toUpperCase();
122
+ }
123
+
124
+ private getHost(stage: string) {
125
+ return stage === "local" ? "localhost" : this.getContainerName(stage);
126
+ }
127
+
128
+ private getHostPort(stage: string) {
129
+ if (this.port != null) return this.port;
130
+ return stage === "local" ? 6379 : 6379;
131
+ }
132
+
133
+ private getVolumeName(stage: string) {
134
+ return this.volume ?? `${stage}-${this.name}-redis-data`.replaceAll("_", "-").toLowerCase();
135
+ }
136
+
137
+ private toOutputs(connection: RedisConnectionInfo): Outputs {
138
+ return {
139
+ redisHost: connection.host,
140
+ redisPort: connection.port,
141
+ redisPassword: connection.password,
142
+ redisUrl: connection.url,
143
+ };
144
+ }
145
+
146
+ private toRedisUrl(host: string, port: string, password: string) {
147
+ return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
148
+ }
149
+
150
+ private async getOrCreatePassword(stage: string) {
151
+ const manager = new SecretsManager({ stage });
152
+ const secretName = `${this.name}-redis-password`;
153
+
154
+ try {
155
+ return await manager.get(secretName);
156
+ } catch (error) {
157
+ if ((error as Error).name !== "ParameterNotFound") throw error;
158
+ }
159
+
160
+ const legacyPassword = await this.getLegacyPassword(stage, secretName);
161
+ if (legacyPassword != null) {
162
+ await manager.set(secretName, legacyPassword);
163
+ return legacyPassword;
164
+ }
165
+
166
+ const password = randomBytes(24).toString("base64url");
167
+ await manager.set(secretName, password);
168
+ return password;
169
+ }
170
+
171
+ private async getLegacyPassword(stage: string, secretName: string) {
172
+ const secretPath = path.resolve(".saws", "secrets", stage, secretName);
173
+
174
+ try {
175
+ return (await readFile(secretPath, "utf8")).trim();
176
+ } catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
178
+ return null;
179
+ }
180
+ }
181
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./RedisService.js";
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig-node.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "tsBuildInfoFile": "./dist/.tsbuildinfo"
7
+ },
8
+ "references": [{ "path": "../core/tsconfig.json" }, { "path": "../docker-service/tsconfig.json" }]
9
+ }