Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): declarative deployment #1702

Merged
merged 59 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
310b30b
add sendTransaction helper
holic Oct 4, 2023
e815a7a
start new deploy command with deployment proxy
holic Oct 4, 2023
68fcad3
deterministic world factory
holic Oct 4, 2023
f7ff52d
deploy world
holic Oct 4, 2023
72c893b
register tables
holic Oct 4, 2023
52394a3
this is no longer the case with resource types
holic Oct 4, 2023
e79e718
register systems
holic Oct 4, 2023
dff6637
improve logs
holic Oct 4, 2023
9df0429
rename create2 dir
holic Oct 4, 2023
681e2af
get world deploy and resource IDs
holic Oct 4, 2023
f26472c
refactor world deploy handling
holic Oct 5, 2023
cf60adb
clarify resourceId (hex) from resource (object)
holic Oct 5, 2023
3aef65e
wip
holic Oct 5, 2023
bcb3cbd
resolve custom types
holic Oct 5, 2023
f374af9
upgrade systems
holic Oct 5, 2023
43949a6
register system functions
holic Oct 5, 2023
5a897fb
wip system functions
holic Oct 5, 2023
29e0506
small clean up
holic Oct 6, 2023
862eb42
register functions
holic Oct 6, 2023
ba64b40
grant/revoke access
holic Oct 6, 2023
d017e9f
install modules
holic Oct 6, 2023
2e3d242
install modules
holic Oct 6, 2023
2e952b4
make sure to pass toBlock into getters
holic Oct 6, 2023
e2d0052
Merge remote-tracking branch 'origin/main' into holic/declarative-dep…
holic Oct 9, 2023
bfe7ec6
revert example change
holic Oct 9, 2023
4abb58f
migrate deploy command
holic Oct 9, 2023
bc5b45e
replace deploy, test, dev-contracts with new deploy internals
holic Oct 10, 2023
f271c13
more clean up
holic Oct 10, 2023
2d4b602
Merge remote-tracking branch 'origin/main' into holic/declarative-dep…
holic Oct 10, 2023
bdcce1d
revert as const for now, remove invalid test
holic Oct 10, 2023
edf84df
only run postdeploy for fresh worlds
holic Oct 10, 2023
839e665
opts -> options
holic Oct 10, 2023
3fce375
remove comment
holic Oct 10, 2023
9c6f04f
bump debounce time, add comment
holic Oct 10, 2023
d0fe353
Merge remote-tracking branch 'origin/main' into holic/declarative-dep…
holic Oct 10, 2023
71a106d
use hello events from store/world
holic Oct 10, 2023
8d1acd5
clarify comment
holic Oct 10, 2023
ad4efd8
add store/world version check
holic Oct 10, 2023
ac664d1
assert namespace owner
holic Oct 10, 2023
130d661
update comment
holic Oct 10, 2023
c11956b
rename to/from block
holic Oct 10, 2023
96f9769
update comments
holic Oct 10, 2023
f0ce45d
consistency
holic Oct 10, 2023
bed3180
clarify vars
holic Oct 10, 2023
6d385fb
Update packages/cli/src/deploy/resolveConfig.ts
holic Oct 10, 2023
7028e31
Update packages/cli/src/runDeploy.ts
holic Oct 10, 2023
534eb96
Update packages/cli/src/runDeploy.ts
holic Oct 10, 2023
9c35a5a
Update packages/cli/src/deploy/deploy.ts
holic Oct 10, 2023
745b883
fix/remove TODOs
holic Oct 10, 2023
9419c02
fix test data script, update test data
holic Oct 11, 2023
6d28203
fix e2e
holic Oct 11, 2023
5857688
fix user types internal type
holic Oct 11, 2023
37fd725
update snapshots
holic Oct 11, 2023
bc2c714
no longer a constraint
holic Oct 11, 2023
5c3b409
Create wicked-pens-promise.md
holic Oct 11, 2023
4c7e7ff
Create poor-bags-stare.md
holic Oct 11, 2023
1791c65
Update poor-bags-stare.md
holic Oct 11, 2023
360009d
simplify function sig usage, remove unused utils
holic Oct 11, 2023
339ffd1
Update poor-bags-stare.md
holic Oct 11, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/poor-bags-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@latticexyz/cli": major
---

`deploy`, `test`, `dev-contracts` were overhauled using a declarative deployment approach under the hood. Deploys are now idempotent and re-running them will introspect the world and figure out the minimal changes necessary to bring the world into alignment with its config: adding tables, adding/upgrading systems, changing access control, etc.

The following CLI arguments are now removed from these commands:

- `--debug` (you can now adjust CLI output with `DEBUG` environment variable, e.g. `DEBUG=mud:*`)
- `--priorityFeeMultiplier` (now calculated automatically)
- `--disableTxWait` (everything is now parallelized with smarter nonce management)
- `--pollInterval` (we now lean on viem defaults and we don't wait/poll until the very end of the deploy)

Most deployment-in-progress logs are now behind a [debug](https://github.com/debug-js/debug) flag, which you can enable with a `DEBUG=mud:*` environment variable.
5 changes: 5 additions & 0 deletions .changeset/wicked-pens-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/world": patch
---

With [resource types in resource IDs](https://github.com/latticexyz/mud/pull/1544), the World config no longer requires table and system names to be unique.
2 changes: 1 addition & 1 deletion e2e/packages/contracts/worlds.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"31337": {
"address": "0x0355B7B8cb128fA5692729Ab3AAa199C1753f726"
"address": "0x97e55ad21ee5456964460c5465eac35861d2e797"
}
}
2 changes: 1 addition & 1 deletion e2e/packages/sync-test/setup/deployContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import chalk from "chalk";
import { execa } from "execa";

export function deployContracts(rpc: string) {
const deploymentProcess = execa("pnpm", ["mud", "deploy", "--rpc", rpc, "--disableTxWait"], {
const deploymentProcess = execa("pnpm", ["mud", "deploy", "--rpc", rpc], {
cwd: "../contracts",
stdio: "pipe",
});
Expand Down
21 changes: 11 additions & 10 deletions e2e/packages/test-data/generate-test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ await anvil.start();
const rpc = `http://${anvil.host}:${anvil.port}`;

console.log("deploying world");
const { stdout, stderr } = await execa(
"pnpm",
["mud", "deploy", "--rpc", rpc, "--disableTxWait", "--saveDeployment", "false"],
{
cwd: "../contracts",
stdio: "pipe",
}
);
const { stdout, stderr } = await execa("pnpm", ["mud", "deploy", "--rpc", rpc, "--saveDeployment", "false"], {
cwd: "../contracts",
stdio: "pipe",
env: {
DEBUG: "mud:*",
},
});
if (stderr) console.error(stderr);
if (stdout) console.log(stdout);

Expand Down Expand Up @@ -101,5 +100,7 @@ const logs = await publicClient.request({
console.log("writing", logs.length, "logs to", logsFilename);
await fs.writeFile(logsFilename, JSON.stringify(logs, null, 2));

console.log("stopping anvil");
await anvil.stop();
// TODO: figure out why anvil doesn't stop immediately
// console.log("stopping anvil");
// await anvil.stop();
process.exit(0);
2 changes: 1 addition & 1 deletion examples/minimal/packages/contracts/worlds.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"blockNumber": 21817970
},
"31337": {
"address": "0x5FbDB2315678afecb367f032d93F642f64180aa3"
"address": "0x6e9474e9c83676b9a71133ff96db43e7aa0a4342"
}
}
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@latticexyz/world-modules": "workspace:*",
"chalk": "^5.0.1",
"chokidar": "^3.5.3",
"debug": "^4.3.4",
"dotenv": "^16.0.3",
"ejs": "^3.1.8",
"ethers": "^5.7.2",
Expand All @@ -55,6 +56,7 @@
"nice-grpc-web": "^2.0.1",
"openurl": "^1.1.1",
"path": "^0.12.7",
"rxjs": "7.5.5",
"throttle-debounce": "^5.0.0",
"typescript": "5.1.6",
"viem": "1.14.0",
Expand All @@ -63,6 +65,7 @@
"zod-validation-error": "^1.3.0"
},
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/ejs": "^3.1.1",
"@types/glob": "^7.2.0",
"@types/node": "^18.15.11",
Expand Down
37 changes: 7 additions & 30 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,20 @@
import type { CommandModule, Options } from "yargs";
import type { CommandModule } from "yargs";
import { logError } from "../utils/errors";
import { DeployOptions, deployHandler } from "../utils/deployHandler";
import { DeployOptions, deployOptions, runDeploy } from "../runDeploy";

export const yDeployOptions = {
configPath: { type: "string", desc: "Path to the config file" },
clean: { type: "boolean", desc: "Remove the build forge artifacts and cache directories before building" },
printConfig: { type: "boolean", desc: "Print the resolved config" },
profile: { type: "string", desc: "The foundry profile to use" },
debug: { type: "boolean", desc: "Print debug logs, like full error messages" },
priorityFeeMultiplier: {
type: "number",
desc: "Multiply the estimated priority fee by the provided factor",
default: 1,
},
saveDeployment: { type: "boolean", desc: "Save the deployment info to a file", default: true },
rpc: { type: "string", desc: "The RPC URL to use. Defaults to the RPC url from the local foundry.toml" },
worldAddress: { type: "string", desc: "Deploy to an existing World at the given address" },
srcDir: { type: "string", desc: "Source directory. Defaults to foundry src directory." },
disableTxWait: { type: "boolean", desc: "Disable waiting for transactions to be confirmed.", default: false },
pollInterval: {
type: "number",
desc: "Interval in miliseconds to use to poll for transaction receipts / block inclusion",
default: 1000,
},
skipBuild: { type: "boolean", desc: "Skip rebuilding the contracts before deploying" },
} satisfies Record<keyof DeployOptions, Options>;

const commandModule: CommandModule<DeployOptions, DeployOptions> = {
const commandModule: CommandModule<typeof deployOptions, DeployOptions> = {
command: "deploy",

describe: "Deploy MUD contracts",

builder(yargs) {
return yargs.options(yDeployOptions);
return yargs.options(deployOptions);
},

async handler(args) {
async handler(opts) {
// Wrap in try/catch, because yargs seems to swallow errors
try {
await deployHandler(args);
await runDeploy(opts);
} catch (error: any) {
logError(error);
process.exit(1);
Expand Down
199 changes: 62 additions & 137 deletions packages/cli/src/commands/dev-contracts.ts
Original file line number Diff line number Diff line change
@@ -1,176 +1,101 @@
import type { CommandModule } from "yargs";
import {
anvil,
forge,
getRemappings,
getRpcUrl,
getScriptDirectory,
getSrcDirectory,
} from "@latticexyz/common/foundry";
import type { CommandModule, InferredOptionTypes } from "yargs";
import { anvil, getScriptDirectory, getSrcDirectory } from "@latticexyz/common/foundry";
import chalk from "chalk";
import chokidar from "chokidar";
import { loadConfig, resolveConfigPath } from "@latticexyz/config/node";
import { StoreConfig } from "@latticexyz/store";
import { tablegen } from "@latticexyz/store/codegen";
import path from "path";
import { debounce } from "throttle-debounce";
import { worldgenHandler } from "./worldgen";
import { WorldConfig } from "@latticexyz/world";
import { homedir } from "os";
import { rmSync } from "fs";
import { execa } from "execa";
import { logError } from "../utils/errors";
import { deployHandler } from "../utils/deployHandler";
import { printMUD } from "../utils/printMUD";
import { deployOptions, runDeploy } from "../runDeploy";
import { BehaviorSubject, debounceTime, exhaustMap } from "rxjs";
import { Address } from "viem";

type Options = {
rpc?: string;
configPath?: string;
const devOptions = {
rpc: deployOptions.rpc,
configPath: deployOptions.configPath,
};

const commandModule: CommandModule<Options, Options> = {
const commandModule: CommandModule<typeof devOptions, InferredOptionTypes<typeof devOptions>> = {
command: "dev-contracts",

describe: "Start a development server for MUD contracts",

builder(yargs) {
return yargs.options({
rpc: {
type: "string",
decs: "RPC endpoint of the development node. If none is provided, an anvil instance is spawned in the background on port 8545.",
},
configPath: {
type: "string",
decs: "Path to MUD config",
},
});
return yargs.options(devOptions);
},

async handler(args) {
// Initial cleanup
await forge(["clean"]);

const rpc = args.rpc ?? (await getRpcUrl());
const configPath = args.configPath ?? (await resolveConfigPath(args.configPath));
const srcDirectory = await getSrcDirectory();
const scriptDirectory = await getScriptDirectory();
const remappings = await getRemappings();
async handler(opts) {
let rpc = opts.rpc;
const configPath = opts.configPath ?? (await resolveConfigPath(opts.configPath));
const srcDir = await getSrcDirectory();
const scriptDir = await getScriptDirectory();
const initialConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig;

// Initial run of all codegen steps before starting anvil
// (so clients can wait for everything to be ready before starting)
await handleConfigChange(initialConfig);
await handleContractsChange(initialConfig);

// Start an anvil instance in the background if no RPC url is provided
if (!args.rpc) {
if (!opts.rpc) {
// Clean anvil cache as 1s block times can fill up the disk
// - https://github.com/foundry-rs/foundry/issues/3623
// - https://github.com/foundry-rs/foundry/issues/4989
// - https://github.com/foundry-rs/foundry/issues/3699
// - https://github.com/foundry-rs/foundry/issues/3512
console.log(chalk.gray("Cleaning devnode cache"));
const userHomeDir = homedir();
rmSync(path.join(userHomeDir, ".foundry", "anvil", "tmp"), { recursive: true, force: true });

const anvilArgs = ["--block-time", "1", "--block-base-fee-per-gas", "0"];
anvil(anvilArgs);
rpc = "http://127.0.0.1:8545";
}

const changedSinceLastHandled = {
config: false,
contracts: false,
};

const changeInProgress = {
current: false,
};

// Watch for changes
const configWatcher = chokidar.watch([configPath, srcDirectory]);
configWatcher.on("all", async (_, updatePath) => {
const lastChange$ = new BehaviorSubject<number>(Date.now());
chokidar.watch([configPath, srcDir, scriptDir]).on("all", async (_, updatePath) => {
if (updatePath.includes(configPath)) {
changedSinceLastHandled.config = true;
// We trigger contract changes if the config changed here instead of
// listening to changes in the codegen directory to avoid an infinite loop
changedSinceLastHandled.contracts = true;
lastChange$.next(Date.now());
}

if (updatePath.includes(srcDirectory) || updatePath.includes(scriptDirectory)) {
if (updatePath.includes(srcDir) || updatePath.includes(scriptDir)) {
// Ignore changes to codegen files to avoid an infinite loop
if (updatePath.includes(initialConfig.codegenDirectory)) return;
changedSinceLastHandled.contracts = true;
if (!updatePath.includes(initialConfig.codegenDirectory)) {
lastChange$.next(Date.now());
}
}

// Trigger debounced onChange
handleChange();
});

const handleChange = debounce(100, async () => {
// Avoid handling changes multiple times in parallel
if (changeInProgress.current) return;
changeInProgress.current = true;

// Reset dirty flags
const { config, contracts } = changedSinceLastHandled;
changedSinceLastHandled.config = false;
changedSinceLastHandled.contracts = false;

try {
// Load latest config
const mudConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig;

// Handle changes
if (config) await handleConfigChange(mudConfig);
if (contracts) await handleContractsChange(mudConfig);

await deploy();
} catch (error) {
console.error(chalk.red("MUD dev-contracts watcher failed to deploy config or contracts changes\n"));
logError(error);
}

changeInProgress.current = false;
if (changedSinceLastHandled.config || changedSinceLastHandled.contracts) {
console.log("Detected change while handling the previous change");
handleChange();
}

printMUD();
console.log("MUD watching for changes...");
});

/** Codegen to run if config changes */
async function handleConfigChange(config: StoreConfig & WorldConfig) {
console.log(chalk.blue("mud.config.ts changed - regenerating tables and recs types"));
// Run tablegen to generate tables based on the config
const outPath = path.join(srcDirectory, config.codegenDirectory);
await tablegen(config, outPath, remappings);
}

/** Codegen to run if contracts changed */
async function handleContractsChange(config: StoreConfig & WorldConfig) {
console.log(chalk.blue("contracts changed - regenerating interfaces and contract types"));

// Run worldgen to generate interfaces based on the systems
await worldgenHandler({ config, clean: true, srcDir: srcDirectory });

// Build the contracts
await forge(["build", "--skip", "test", "script"]);

// Generate TS type definitions for ABIs
await execa("mud", ["abi-ts"], { stdio: "inherit" });
}

/** Run after codegen if either mud config or contracts changed */
async function deploy() {
console.log(chalk.blue("redeploying World"));
await deployHandler({
configPath,
skipBuild: true,
priorityFeeMultiplier: 1,
disableTxWait: true,
pollInterval: 1000,
saveDeployment: true,
srcDir: srcDirectory,
rpc,
});
}
let worldAddress: Address | undefined;

const deploys$ = lastChange$.pipe(
// debounce so that a large batch of file changes only triggers a deploy after it settles down, rather than the first change it sees (and then redeploying immediately after)
debounceTime(200),
exhaustMap(async (lastChange) => {
if (worldAddress) {
console.log(chalk.blue("Change detected, rebuilding and running deploy..."));
}
// TODO: handle errors
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens currently if there is an error during the deploy? would the dev process stop or just wait until the next change?

Copy link
Member Author

@holic holic Oct 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right now things just explode and the process dies (~fine)

I tried to wrap in a try/catch but still got weird behavior

ideally things would re-run once files change, regardless of error, but didn't wanna rabbit hole too long on that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah if i'm not mistaken that was the previous behavior (e.g. if there is a typo in a contract the dev runner logged the error but kept watching to rerun when the typo was fixed). I'm fine with not blocking this PR on this, but think we should do it in a followup soon.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a follow up issue here: #1731

const deploy = await runDeploy({
holic marked this conversation as resolved.
Show resolved Hide resolved
configPath,
rpc,
clean: true,
skipBuild: false,
printConfig: false,
profile: undefined,
saveDeployment: true,
worldAddress,
srcDir,
});
worldAddress = deploy.address;
// if there were changes while we were deploying, trigger it again
if (lastChange < lastChange$.value) {
lastChange$.next(lastChange$.value);
} else {
console.log(chalk.gray("Watching for file changes..."));
}
return deploy;
})
);

deploys$.subscribe();
},
};

Expand Down
Loading