Skip to content

Commit 2a176ee

Browse files
authored
feat!: Store the typegraph on s3 (#620)
Store the typegraph on s3 for multiple instance support mode. #### Motivation and context Reduce Redis data. #### Migration notes Environment variables: - `REDIS_URL` has been removed - For multiple instance support, the following variables are required: `SYNC_REDIS_URL`, `SYNC_S3_HOST`, `SYNC_S3_REGION`, `SYNC_S3_BUCKET`, `SYNC_S3_ACCESS_KEY`, `SYNC_S3_SECRET_KEY`; and the following variables are optional: `SYNC_REDIS_PASSWORD`, `SYNC_S3_PATH_STYLE`. Otherwise, none of them can be set. ### Checklist - [x] The change come with new or modified tests - [ ] Hard-to-understand functions have explanatory comments - [x] End-user documentation is updated to reflect the change
1 parent 068b56e commit 2a176ee

File tree

36 files changed

+1659
-311
lines changed

36 files changed

+1659
-311
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ jobs:
243243
ports:
244244
- 6379:6379
245245
options: >-
246-
--health-cmd "redis-cli ping"
246+
--health-cmd "redis-cli -a password ping"
247247
--health-interval 10s
248248
--health-timeout 5s
249249
--health-retries 5

dev/test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ const env: Record<string, string> = {
100100
"TG_SECRET":
101101
"a4lNi0PbEItlFZbus1oeH/+wyIxi9uH6TpL8AIqIaMBNvp7SESmuUBbfUwC0prxhGhZqHw8vMDYZAGMhSZ4fLw==",
102102
"TG_ADMIN_PASSWORD": "password",
103-
"REDIS_URL": "redis://:password@localhost:6379/0",
104103
"DENO_TESTING": "true",
105104
"TMP_DIR": tmpDir,
106105
"TIMER_MAX_TIMEOUT_MS": "30000",

docs/workflows/typegraph_deployment.drawio.svg

Lines changed: 290 additions & 0 deletions
Loading

meta-cli/src/cli/deploy.rs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0.
22
// SPDX-License-Identifier: MPL-2.0
33

4+
use std::collections::HashMap;
45
use std::path::{Path, PathBuf};
56
use std::sync::{Arc, Mutex};
67

@@ -89,6 +90,26 @@ pub struct DeployOptions {
8990
/// Run in watch mode
9091
#[clap(long, default_value_t = false)]
9192
pub watch: bool,
93+
94+
/// secret overrides
95+
#[clap(long = "secret")]
96+
pub secrets: Vec<String>,
97+
}
98+
99+
fn override_secrets(
100+
secrets: HashMap<String, String>,
101+
overrides: Vec<String>,
102+
) -> Result<HashMap<String, String>> {
103+
let mut secrets = secrets;
104+
for override_str in overrides {
105+
let parts: Vec<&str> = override_str.splitn(2, '=').collect();
106+
if parts.len() != 2 {
107+
bail!("Invalid secret override: {}", override_str);
108+
}
109+
secrets.insert(parts[0].to_string(), parts[1].to_string());
110+
}
111+
112+
Ok(secrets)
92113
}
93114

94115
pub struct Deploy {
@@ -113,7 +134,7 @@ impl Deploy {
113134
let node = node_config
114135
.build(&dir)
115136
.await
116-
.with_context(|| format!("building node from config: {node_config:#?}"))?;
137+
.with_context(|| format!("error while building node from config: {node_config:#?}"))?;
117138

118139
ServerStore::with(Some(Command::Deploy), Some(config.as_ref().to_owned()));
119140
ServerStore::set_migration_action_glob(MigrationAction {
@@ -203,8 +224,11 @@ mod default_mode {
203224
impl DefaultMode {
204225
pub async fn init(deploy: Deploy) -> Result<Self> {
205226
let console = ConsoleActor::new(Arc::clone(&deploy.config)).start();
206-
let secrets =
207-
lade_sdk::hydrate(deploy.node.env.clone(), deploy.base_dir.to_path_buf()).await?;
227+
let secrets = lade_sdk::hydrate(
228+
override_secrets(deploy.node.env.clone(), deploy.options.secrets.clone())?,
229+
deploy.base_dir.to_path_buf(),
230+
)
231+
.await?;
208232

209233
ServerStore::set_secrets(secrets);
210234

@@ -323,8 +347,11 @@ mod watch_mode {
323347
.context("setting Ctrl-C handler")?;
324348

325349
loop {
326-
let secrets =
327-
lade_sdk::hydrate(deploy.node.env.clone(), deploy.base_dir.to_path_buf()).await?;
350+
let secrets = lade_sdk::hydrate(
351+
override_secrets(deploy.node.env.clone(), deploy.options.secrets.clone())?,
352+
deploy.base_dir.to_path_buf(),
353+
)
354+
.await?;
328355

329356
ServerStore::set_secrets(secrets.clone());
330357

meta-cli/src/cli/dev.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ pub struct Dev {
2525

2626
#[clap(long)]
2727
max_parallel_loads: Option<usize>,
28+
29+
/// secrets overload
30+
#[clap(long = "secret")]
31+
secrets: Vec<String>,
2832
}
2933

3034
#[async_trait]
@@ -38,6 +42,7 @@ impl Action for Dev {
3842
watch: true,
3943
no_migration: false,
4044
create_migration: true,
45+
secrets: self.secrets.clone(),
4146
};
4247

4348
let deploy = DeploySubcommand::new(

typegate/deno.lock

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

typegate/import_map.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"zod": "https://deno.land/x/[email protected]/mod.ts",
1515
"monads": "https://deno.land/x/[email protected]/mod.ts",
1616
"jwt": "https://deno.land/x/[email protected]/mod.ts",
17-
"redis": "https://deno.land/x/redis@v0.31.0/mod.ts",
17+
"redis": "https://deno.land/x/redis@v0.32.1/mod.ts",
1818
"oauth2_client": "https://deno.land/x/[email protected]/mod.ts",
1919
"test/mock_fetch": "https://deno.land/x/[email protected]/mod.ts",
2020
"levenshtein": "https://deno.land/x/[email protected]/mod.ts",

typegate/src/config.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { mapKeys } from "std/collections/map_keys.ts";
66

77
import * as base64 from "std/encoding/base64.ts";
88
import { parse } from "std/flags/mod.ts";
9-
import { RedisConnectOptions } from "redis";
109
import { join } from "std/path/mod.ts";
1110
// This import ensure log loads before config, important for the version hydration
1211
import { configOrExit, zBooleanString } from "./log.ts";
@@ -17,18 +16,6 @@ const schema = {
1716
// If false, auto reload system typegraphs on change. Default: to true.
1817
packaged: zBooleanString,
1918
hostname: z.string(),
20-
redis_url: z
21-
.string()
22-
.transform((s: string) => {
23-
if (s == "none") {
24-
return new URL("redis://none");
25-
}
26-
const url = new URL(s);
27-
if (url.password === "") {
28-
url.password = Deno.env.get("REDIS_PASSWORD") ?? "";
29-
}
30-
return url;
31-
}),
3219
tg_port: z.coerce.number().positive().max(65535),
3320
tg_secret: z.string().transform((s: string, ctx) => {
3421
const bytes = base64.decode(s);
@@ -94,12 +81,3 @@ const config = await configOrExit([
9481
], schema);
9582

9683
export default config;
97-
98-
export const redisConfig: RedisConnectOptions = {
99-
hostname: config.redis_url.hostname,
100-
port: config.redis_url.port,
101-
...config.redis_url.password.length > 0
102-
? { password: config.redis_url.password }
103-
: {},
104-
db: parseInt(config.redis_url.pathname.substring(1), 10),
105-
};

typegate/src/main.ts

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
// Copyright Metatype OÜ, licensed under the Elastic License 2.0.
22
// SPDX-License-Identifier: Elastic-2.0
33

4-
import { deferred } from "std/async/deferred.ts";
54
import { init_native } from "native";
65

7-
import { Register, ReplicatedRegister } from "./typegate/register.ts";
8-
import config, { redisConfig } from "./config.ts";
6+
import config from "./config.ts";
97
import { Typegate } from "./typegate/mod.ts";
10-
import { RateLimiter, RedisRateLimiter } from "./typegate/rate_limiter.ts";
118
import { SystemTypegraph } from "./system_typegraphs.ts";
129
import * as Sentry from "sentry";
1310
import { getLogger } from "./log.ts";
1411
import { init_runtimes } from "./runtimes/mod.ts";
15-
import { MemoryRegister } from "test-utils/memory_register.ts";
16-
import { NoLimiter } from "test-utils/no_limiter.ts";
12+
import { syncConfigFromEnv } from "./sync/config.ts";
1713

1814
const logger = getLogger(import.meta);
1915

@@ -50,32 +46,8 @@ try {
5046
// load all runtimes
5147
await init_runtimes();
5248

53-
const deferredTypegate = deferred<Typegate>();
54-
let register: Register | undefined;
55-
let limiter: RateLimiter | undefined;
56-
57-
if (redisConfig.hostname != "none") {
58-
register = await ReplicatedRegister.init(deferredTypegate, redisConfig);
59-
limiter = await RedisRateLimiter.init(redisConfig);
60-
} else {
61-
logger.warning("Entering Redis-less mode");
62-
register = new MemoryRegister();
63-
limiter = new NoLimiter();
64-
}
65-
66-
const typegate = new Typegate(register!, limiter!);
67-
68-
deferredTypegate.resolve(typegate);
69-
70-
if (register instanceof ReplicatedRegister) {
71-
const lastSync = await register.historySync().catch((err) => {
72-
logger.error(err);
73-
throw new Error(
74-
`failed to load history at boot, aborting: ${err.message}`,
75-
);
76-
});
77-
register.startSync(lastSync);
78-
}
49+
const syncConfig = await syncConfigFromEnv(["vars", "args"]);
50+
const typegate = await Typegate.init(syncConfig);
7951

8052
await SystemTypegraph.loadAll(typegate, !config.packaged);
8153

typegate/src/sync/config.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright Metatype OÜ, licensed under the Elastic License 2.0.
2+
// SPDX-License-Identifier: Elastic-2.0
3+
4+
import { z } from "zod";
5+
import { configOrExit, zBooleanString } from "../log.ts";
6+
import { RedisConnectOptions } from "redis";
7+
import { S3ClientConfig } from "aws-sdk/client-s3";
8+
import { mapKeys } from "std/collections/map_keys.ts";
9+
import { parse } from "std/flags/mod.ts";
10+
11+
export const syncConfigSchemaNaked = {
12+
sync_redis_url: z.string().optional().transform((s) => {
13+
if (s == undefined) return undefined;
14+
const url = new URL(s);
15+
if (url.password === "") {
16+
url.password = Deno.env.get("SYNC_REDIS_PASSWORD") ?? "";
17+
}
18+
return url;
19+
}),
20+
sync_s3_host: z.string().optional().transform((s) => {
21+
if (s == undefined) return undefined;
22+
return new URL(s);
23+
}),
24+
sync_s3_region: z.string().optional(),
25+
sync_s3_bucket: z.string().optional(),
26+
sync_s3_access_key: z.string().optional(),
27+
sync_s3_secret_key: z.string().optional(),
28+
sync_s3_path_style: zBooleanString.optional(),
29+
};
30+
31+
export const syncConfigSchema = z.object(syncConfigSchemaNaked);
32+
33+
export type SyncConfigRaw = Required<z.output<typeof syncConfigSchema>>;
34+
35+
export function validateSyncConfig(
36+
config: z.output<typeof syncConfigSchema>,
37+
): SyncConfigRaw | null {
38+
const syncVars = new Set(
39+
Object.keys(config).filter((key) => key.startsWith("sync_")),
40+
);
41+
42+
if (syncVars.size === 0) {
43+
return null;
44+
}
45+
46+
const missingVars = Object.keys(syncConfigSchemaNaked).filter(
47+
(key) => {
48+
const value = config[key as keyof typeof config];
49+
if (value != undefined) return false;
50+
51+
// not required - set to default
52+
if (key === "sync_s3_path_style") {
53+
config.sync_s3_path_style = false;
54+
return false;
55+
}
56+
return true;
57+
},
58+
).map((key) => key.toUpperCase());
59+
60+
if (missingVars.length > 0) {
61+
const missingVarsStr = missingVars.join(", ");
62+
const msg = `Environment variables required for sync: ${missingVarsStr}.`;
63+
const suggestion =
64+
"Make sure to set these variables or set SYNC_ENABLED=false.";
65+
throw new Error(`${msg}\n${suggestion}`);
66+
}
67+
68+
const finalConfig = config as SyncConfigRaw;
69+
return finalConfig;
70+
}
71+
72+
export type SyncConfig = {
73+
redis: RedisConnectOptions;
74+
s3: S3ClientConfig;
75+
s3Bucket: string;
76+
};
77+
78+
export function syncConfigFromRaw(
79+
config: SyncConfigRaw | null,
80+
): SyncConfig | null {
81+
if (config == null) return null;
82+
83+
const redisDbStr = config.sync_redis_url.pathname.substring(1);
84+
const redisDb = parseInt(redisDbStr, 10);
85+
if (isNaN(redisDb)) {
86+
throw new Error(`Invalid redis db number: '${redisDbStr}'`);
87+
}
88+
89+
return {
90+
redis: {
91+
hostname: config.sync_redis_url.hostname,
92+
port: config.sync_redis_url.port,
93+
...config.sync_redis_url.password.length > 0
94+
? { password: config.sync_redis_url.password }
95+
: {},
96+
db: parseInt(config.sync_redis_url.pathname.substring(1), 10),
97+
},
98+
s3: {
99+
endpoint: config.sync_s3_host.href,
100+
region: config.sync_s3_region,
101+
credentials: {
102+
accessKeyId: config.sync_s3_access_key,
103+
secretAccessKey: config.sync_s3_secret_key,
104+
},
105+
forcePathStyle: config.sync_s3_path_style,
106+
},
107+
s3Bucket: config.sync_s3_bucket,
108+
};
109+
}
110+
111+
export type ConfigSource = "vars" | "args";
112+
113+
export async function syncConfigFromEnv(
114+
sources: ConfigSource[],
115+
): Promise<SyncConfig | null> {
116+
const rawObjects = sources.map((source) => {
117+
switch (source) {
118+
case "vars":
119+
return mapKeys(
120+
Deno.env.toObject(),
121+
(k: string) => k.toLowerCase(),
122+
);
123+
case "args":
124+
return parse(Deno.args) as Record<string, unknown>;
125+
}
126+
});
127+
const syncConfigRaw = await configOrExit(rawObjects, syncConfigSchemaNaked);
128+
129+
return syncConfigFromRaw(validateSyncConfig(syncConfigRaw));
130+
}

typegate/src/sync/mod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright Metatype OÜ, licensed under the Elastic License 2.0.
2+
// SPDX-License-Identifier: Elastic-2.0
3+
4+
//

0 commit comments

Comments
 (0)