feat(maitred): Update maitred - hookup to the API (#198)

## Description
We are attempting to hookup maitred to the API
Maitred duties will be:
- [ ] Hookup to the API
- [ ]  Wait for signal (from the API) to start Steam
- [ ] Stop signal to stop the gaming session, clean up Steam... and
maybe do the backup

## Summary by CodeRabbit

- **New Features**
- Introduced Docker-based deployment configurations for both the main
and relay applications.
- Added new API endpoints enabling real-time machine messaging and
enhanced IoT operations.
- Expanded database schema and actor types to support improved machine
tracking.

- **Improvements**
- Enhanced real-time communication and relay management with streamlined
room handling.
- Upgraded dependencies, logging, and error handling for greater
stability and performance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
Wanjohi
2025-04-07 23:23:53 +03:00
committed by GitHub
parent 6990494b34
commit de80f3e6ab
84 changed files with 7357 additions and 1331 deletions

View File

@@ -37,15 +37,25 @@ export const SystemActor = z.object({
});
export type SystemActor = z.infer<typeof SystemActor>;
export const MachineActor = z.object({
type: z.literal("machine"),
properties: z.object({
fingerprint: z.string(),
machineID: z.string(),
}),
});
export type MachineActor = z.infer<typeof MachineActor>;
export const Actor = z.discriminatedUnion("type", [
MemberActor,
UserActor,
PublicActor,
SystemActor,
MachineActor
]);
export type Actor = z.infer<typeof Actor>;
const ActorContext = createContext<Actor>("actor");
export const ActorContext = createContext<Actor>("actor");
export const useActor = ActorContext.use;
export const withActor = ActorContext.with;
@@ -74,6 +84,12 @@ export function useTeam() {
throw new Error(`Expected actor to have teamID`);
}
export function useMachine() {
const actor = useActor();
if ("machineID" in actor.properties) return actor.properties.fingerprint;
throw new Error(`Expected actor to have fingerprint`);
}
export async function assertUserFlag(flag: keyof UserFlags) {
return useTransaction((tx) =>
tx

View File

@@ -31,4 +31,13 @@ export module Examples {
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Machine = {
id: Id("machine"),
country: "Kenya",
countryCode: "KE",
timezone: "Africa/Nairobi",
location: { latitude: 36.81550, longitude: -1.28410 },
fingerprint: "fc27f428f9ca47d4b41b707ae0c62090",
}
}

View File

@@ -0,0 +1,138 @@
import { z } from "zod";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
import { machineTable } from "./machine.sql";
import { getTableColumns, eq, sql, and, isNull } from "../drizzle";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export module Machine {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Machine.id,
}),
country: z.string().openapi({
description: "The fullname of the country this machine is running in",
example: Examples.Machine.country
}),
fingerprint: z.string().openapi({
description: "The fingerprint of this machine, deduced from the host machine's machine id - /etc/machine-id",
example: Examples.Machine.fingerprint
}),
location: z.object({ longitude: z.number(), latitude: z.number() }).openapi({
description: "This is the 2d location of this machine, they might not be accurate",
example: Examples.Machine.location
}),
countryCode: z.string().openapi({
description: "This is the 2 character country code of the country this machine [ISO 3166-1 alpha-2] ",
example: Examples.Machine.countryCode
}),
timezone: z.string().openapi({
description: "The IANA timezone formatted string of the timezone of the location where the machine is running",
example: Examples.Machine.timezone
})
})
.openapi({
ref: "Machine",
description: "Represents a hosted or BYOG machine connected to Nestri",
example: Examples.Machine,
});
export type Info = z.infer<typeof Info>;
export const create = fn(Info.partial({ id: true }), async (input) =>
createTransaction(async (tx) => {
const id = input.id ?? createID("machine");
await tx.insert(machineTable).values({
id,
country: input.country,
timezone: input.timezone,
fingerprint: input.fingerprint,
countryCode: input.countryCode,
location: { x: input.location.longitude, y: input.location.latitude },
})
// await afterTx(() =>
// bus.publish(Resource.Bus, Events.Created, {
// teamID: id,
// }),
// );
return id;
})
)
export const list = fn(z.void(), async () =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
.where(isNull(machineTable.timeDeleted))
.then((rows) => rows.map(serialize))
)
)
export const fromID = fn(Info.shape.id, async (id) =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
.where(and(eq(machineTable.id, id), isNull(machineTable.timeDeleted)))
.then((rows) => rows.map(serialize).at(0))
)
)
export const fromFingerprint = fn(Info.shape.fingerprint, async (fingerprint) =>
useTransaction(async (tx) =>
tx
.select()
.from(machineTable)
.where(and(eq(machineTable.fingerprint, fingerprint), isNull(machineTable.timeDeleted)))
.execute()
.then((rows) => rows.map(serialize).at(0))
)
)
export const remove = fn(Info.shape.id, (id) =>
useTransaction(async (tx) => {
await tx
.update(machineTable)
.set({
timeDeleted: sql`now()`,
})
.where(and(eq(machineTable.id, id)))
.execute();
return id;
}),
);
export const fromLocation = fn(Info.shape.location, async (location) =>
useTransaction(async (tx) => {
const sqlDistance = sql`location <-> point(${location.longitude}, ${location.latitude})`;
return tx
.select({
...getTableColumns(machineTable),
distance: sql`round((${sqlDistance})::numeric, 2)`
})
.from(machineTable)
.where(isNull(machineTable.timeDeleted)) //Should have a status update
.orderBy(sqlDistance)
.limit(3)
.then((rows) => rows.map(serialize))
})
)
export function serialize(
input: typeof machineTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
country: input.country,
timezone: input.timezone,
fingerprint: input.fingerprint,
countryCode: input.countryCode,
location: { latitude: input.location.y, longitude: input.location.x },
};
}
}

View File

@@ -0,0 +1,37 @@
import { } from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types";
import {
text,
varchar,
pgTable,
uniqueIndex,
point,
} from "drizzle-orm/pg-core";
export const machineTable = pgTable(
"machine",
{
...id,
...timestamps,
country: text('country').notNull(),
timezone: text('timezone').notNull(),
location: point('location', { mode: 'xy' }).notNull(),
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
countryCode: varchar('country_code', { length: 2 }).notNull(),
// provider: text("provider").notNull(),
// gpuType: text("gpu_type").notNull(),
// storage: numeric("storage").notNull(),
// ipaddress: text("ipaddress").notNull(),
// gpuNumber: integer("gpu_number").notNull(),
// computePrice: numeric("compute_price").notNull(),
// driverVersion: integer("driver_version").notNull(),
// operatingSystem: text("operating_system").notNull(),
// fingerprint: varchar("fingerprint", { length: 32 }).notNull(),
// externalID: varchar("external_id", { length: 255 }).notNull(),
// cudaVersion: numeric("cuda_version", { precision: 4, scale: 2 }).notNull(),
},
(table) => [
// uniqueIndex("external_id").on(table.externalID),
uniqueIndex("machine_fingerprint").on(table.fingerprint)
],
);

View File

@@ -78,16 +78,16 @@ export module Member {
}),
);
export const remove = fn(Info.shape.id, (input) =>
export const remove = fn(Info.shape.id, (id) =>
useTransaction(async (tx) => {
await tx
.update(memberTable)
.set({
timeDeleted: sql`now()`,
})
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
.where(and(eq(memberTable.id, id), eq(memberTable.teamID, useTeam())))
.execute();
return input;
return id;
}),
);
@@ -98,11 +98,10 @@ export module Member {
.from(memberTable)
.where(and(eq(memberTable.email, email), isNull(memberTable.timeDeleted)))
.orderBy(asc(memberTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
),
.then((rows) => rows.map(serialize).at(0))
)
)
export const fromID = fn(z.string(), async (id) =>
useTransaction(async (tx) =>
tx
@@ -110,8 +109,7 @@ export module Member {
.from(memberTable)
.where(and(eq(memberTable.id, id), isNull(memberTable.timeDeleted)))
.orderBy(asc(memberTable.timeCreated))
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0))
.then((rows) => rows.map(serialize).at(0))
),
)

View File

@@ -0,0 +1,24 @@
import {
IoTDataPlaneClient,
PublishCommand,
} from "@aws-sdk/client-iot-data-plane";
import {useMachine} from "../actor";
import {Resource} from "sst";
export module Realtime {
const client = new IoTDataPlaneClient({});
export async function publish(message: any, subTopic?: string) {
const fingerprint = useMachine();
let topic = `${Resource.App.name}/${Resource.App.stage}/${fingerprint}/`;
if (subTopic)
topic = `${topic}${subTopic}`;
await client.send(
new PublishCommand({
payload: Buffer.from(JSON.stringify(message)),
topic: topic,
})
);
}
}

View File

@@ -0,0 +1,20 @@
import { id, timestamps } from "../drizzle/types";
import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core";
//This represents a task created on a machine for running a game
//Add billing info here?
//Add who owns the task here
// Add the session ID here
//Add which machine owns this task
export const taskTable = pgTable(
"task",
{
...id,
...timestamps,
fingerprint: varchar('fingerprint', { length: 32 }).notNull(),
},
(table) => [
uniqueIndex("task_fingerprint").on(table.fingerprint),
],
);

View File

@@ -5,7 +5,7 @@ import { Common } from "../common";
import { Examples } from "../examples";
import { createEvent } from "../event";
import { createID, fn } from "../utils";
import { and, eq, sql } from "../drizzle";
import { and, eq, sql, isNull } from "../drizzle";
import { PlanType, teamTable } from "./team.sql";
import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql";
@@ -111,6 +111,7 @@ export module Team {
tx
.select()
.from(teamTable)
.where(isNull(teamTable.timeDeleted))
.execute()
.then((rows) => rows.map(serialize)),
),
@@ -121,10 +122,9 @@ export module Team {
return tx
.select()
.from(teamTable)
.where(eq(teamTable.id, id))
.where(and(eq(teamTable.id, id), isNull(teamTable.timeDeleted)))
.execute()
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0));
.then((rows) => rows.map(serialize).at(0))
}),
);
@@ -133,10 +133,9 @@ export module Team {
return tx
.select()
.from(teamTable)
.where(eq(teamTable.slug, input))
.where(and(eq(teamTable.slug, input), isNull(teamTable.timeDeleted)))
.execute()
.then((rows) => rows.map(serialize))
.then((rows) => rows.at(0));
.then((rows) => rows.map(serialize).at(0))
}),
);

View File

@@ -183,16 +183,16 @@ export module User {
};
}
export const remove = fn(Info.shape.id, (input) =>
export const remove = fn(Info.shape.id, (id) =>
useTransaction(async (tx) => {
await tx
.update(userTable)
.set({
timeDeleted: sql`now()`,
})
.where(and(eq(userTable.id, input)))
.where(and(eq(userTable.id, id)))
.execute();
return input;
return id;
}),
);

View File

@@ -3,7 +3,9 @@ import { ulid } from "ulid";
export const prefixes = {
user: "usr",
team: "tea",
member: "mbr"
task: "tsk",
machine: "mch",
member: "mbr",
} as const;
export function createID(prefix: keyof typeof prefixes): string {