Canisters Management

Ever struggled/not remembering the right commands to manage canisters. I know it gets well tricky even for us in Waterway Labs.

We at Waterway Labs have many canisters across all our projects like static canisters which we create (fronted, backend) and dynamic canisters (eg. profile canisters) which our backend’s create in the run. So at times it gets hard for us to keep a track of all our canisters.

And thats where we decided to create a canister management system straight into our WWL_backend canister which can be scaled and used by any of our apps with the help of our waterway-mops and adding our manager canister (wwl_backend) as controller to all our backend canisters.

Current Features Implemented (available for both static and dynamic canisters)

  1. Take/Delete Canister Snapshots.
  2. Update Canister Settings.
  3. Delete Canister (our custom detailed implementation here).
  4. Load Canister Snapshots.
  5. Topup Canisters (This is automated too!).
  6. Fetching Canister Info.
  7. Add/Remove Controllers.
  8. Start/Stop Canisters.
  9. List Canister Snapshots.

There are few more features I am working on currently which include:

  • Updating of Wasm (Stables are bit risky updating without warnings).
  • And additional management related to Wasm.

So here comes the technical side
With the help of our very own mops package it is easy for us to scale this management functions across apps.

The Complete management is implemented by 3 main files

  1. CanisterManager - holds the logic and calls the necessary IC Management functions.
  2. CanisterCommands - has all the types needed for the update functions in CanisterManager.
  3. CanisterQueries - has all the types needed for the query functions in CanisterManager.

Our aim is to idempotently manage all our canisters and this is how we are starting with by bringing the entire management under one canister.

Additionally there is feature where our wwl_backend (manager_canister) does a check every 24hrs of all the canister it manages and does automated cycle topups if the cycles fall below its usage (we have a custom formula).

Let me know what you guys think :slight_smile:

10 Likes

Great work on this John, it gives me a lot of peace of mind knowing cycles for the DAO & upcoming projects are automatically managed as we implement more and more features.

1 Like

This is amazing! Thank you

:clap:

2 Likes

Hey John, thank you so much for sharing! I’m always excited to read Motoko code as it’s used in the “wild” :slight_smile:

While reading the code in the CanisterManager I noticed a few patterns that make things more verbose than they have to be. I’ll list out a few suggestions here in case they help you, or anyone reading the forum:
You’re patttern matching on results only to “rewrap” and return them.

let result = await doSomething();
switch (result) {
  case (#ok(value)) {
    return #ok(value);
  };
  case (#err(err)) {
    return #err(err);
  };
};

// Can be simplified to
return await doSomething()

You’re pattern matching on optional values with a return in the null case. This can often be expressed a bit neater using let-else:

let optional = doTheThing();
switch (optional) {
  case (?value) {
    // ... lots of logic
    return result
  };
  case null {}
}
return error

// Can be simplified to
let ?value = doTheThing() else {
  return error
};
// ... lots of logic
return result

Applying both of these refactors to listCanisterSnapshots as an example:

        public func listCanisterSnapshots(dto : CanisterQueries.ListCanisterSnapshots, projects : [(MopsEnums.WaterwayLabsApp, AppTypes.Project)]) : async Result.Result<[CanisterQueries.CanisterSnapshot], MopsEnums.Error> {
            let projectResult = getProject(dto.app, projects);

            switch (projectResult) {
                case (?_) {
                    let res = await wwlCanisterManager.listCanisterSnapshots(dto);
                    switch (res) {
                        case (#ok(snapshots)) {
                            return #ok(snapshots);
                        };
                        case (#err(err)) {
                            return #err(err);
                        };
                    };
                };
                case (null) {};
            };
            return #err(#NotFound);
        };

        // Can be simplified to

        public func listCanisterSnapshots(dto : CanisterQueries.ListCanisterSnapshots, projects : [(MopsEnums.WaterwayLabsApp, AppTypes.Project)]) : async Result.Result<[CanisterQueries.CanisterSnapshot], MopsEnums.Error> {
            let ?_ = getProject(dto.app, projects) else {
                return #err(#NotFound);
            };
            return await wwlCanisterManager.listCanisterSnapshots(dto);
        };
5 Likes

Hey @kritzcreek thats brilliant and cleaner…Thank you so much for sharing, will refactor right away!

1 Like

Similarly you could simplify

switch (result1) {
    case () {
        let result2 = await CanisterUtilities.loadCanisterSnapshot_(canister_actor, IC, dto.snapshotId);
        switch (result2) {
            case () {
                let result3 = await CanisterUtilities.startCanister_(canister_actor, IC);
                switch (result3) {
                    case () {
                        return #ok(());
                    };
                };
            };
        };
    };
};

Into

let () = await CanisterUtilities.stopCanister_(canister_actor, IC);
let () = await CanisterUtilities.loadCanisterSnapshot_(canister_actor, IC, dto.snapshotId);
let () = await CanisterUtilities.startCanister_(canister_actor, IC);
return #ok(());

More info on patterns can be found here, in the language reference :smiley: (We definitely need to expand on the examples in our docs)

4 Likes

Thank you @Kamirus, doing some huge refactors!

1 Like

@Kamirus @kritzcreek

how do i use the let-else in this case … i want to return the same error from validate func

        public func createSupportQuery(dto : SupportQueryCommands.CreateSupportQuery) : async Result.Result<(), MopsEnums.Error> {
            let valid = validate(dto);
            switch (valid) {
                case (#ok valid) {
                    //todo create
                    return #ok();
                };
                case (#err error) {
                    return #err(error);
                };
            };
        };

        //After using let-else
        public func createSupportQuery(dto : SupportQueryCommands.CreateSupportQuery) : async Result.Result<(), MopsEnums.Error> {
            let #ok(valid) = validate(dto) else {
                return #err(#NotFound);
            };
            //todo create

        };

At the moment you’d need an extra binding:

        //After using let-else
        public func createSupportQuery(dto : SupportQueryCommands.CreateSupportQuery) : async Result.Result<(), MopsEnums.Error> {
            let validateResult = validate(dto);
            let #ok(valid) = validateResult else {
                return validateResult;
            };
            //todo create

        };
1 Like

does the vaildateResult be the err variant ?
I tried to do something similar but didn’t help


Whooops, sorry that was nonsense… This is probably how I’d do it right now (I actually typechecked it this time…):

        public func createSupportQuery(dto : SupportQueryCommands.CreateSupportQuery) : async Result.Result<(), MopsEnums.Error> {
            let valid = switch (validate(dto)) {
              case (#ok ok) ok;
              case (#err err) return #err err;
            };
            //todo create
        };

I am looking into how we could make this a bit more ergonomic.

2 Likes