mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 23:24:52 +09:00
Add release channels as instance version targets
An instance's factorio.version can now target a release channel such as "stable" or "experimental" (any key from factorio.com/api/latest-releases) in addition to "latest" and specific X.Y / X.Y.Z versions. The controller proxies the latest-releases API through a ValueCache and persists the last successful response to latest-releases.json, serving that copy when the API is unreachable. On instance start the host resolves a channel to the concrete version it points to and feeds it into the existing checkForUpdates/findVersion path, so a channel auto-installs and runs like a pinned version. The version selector in the web UI lists the available channels alongside "latest". Also clarifies the host log/error when a newer version cannot be downloaded automatically because the host uses a direct (single-version) Factorio install rather than a versioned directory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a274fe74e1
commit
9da48d5387
15 files changed
+226
-13
No files matched your search
@@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link {
|
||||
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
|
||||
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
|
||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
@@ -113,4 +114,8 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,28 @@ export default class Controller {
|
||||
}
|
||||
});
|
||||
|
||||
// Cache for the latest stable/experimental factorio releases. Persisted to
|
||||
// disk so that release channel targets can still be resolved when
|
||||
// factorio.com is unavailable.
|
||||
latestReleases = new lib.ValueCache(async () => {
|
||||
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
|
||||
try {
|
||||
const releases = await lib.fetchLatestReleases();
|
||||
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
|
||||
return releases;
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
|
||||
} catch (readErr: any) {
|
||||
if (readErr.code !== "ENOENT") {
|
||||
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||
|
||||
@@ -642,6 +642,21 @@ end`.replace(/\r?\n/g, " ");
|
||||
try {
|
||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
await this._loadStats();
|
||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||
// version using the controller's cached latest-releases data, so the
|
||||
// existing download/lookup logic can fetch and run it.
|
||||
const targetVersion = this.config.get("factorio.version") as string;
|
||||
if (lib.isReleaseChannel(targetVersion)) {
|
||||
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Cannot resolve Factorio release channel '${targetVersion}': ` +
|
||||
"latest-releases unavailable and no cached copy on the controller."
|
||||
);
|
||||
}
|
||||
this.server.setTargetVersion(resolved);
|
||||
}
|
||||
await this.server.checkForUpdates(factorioVersions);
|
||||
await this.server.init();
|
||||
} catch (err) {
|
||||
|
||||
@@ -190,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
||||
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
|
||||
return [path.join(factorioDir, "data"), directVersion];
|
||||
}
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
throw new Error(
|
||||
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
|
||||
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise assume it is a directory of multiple versions
|
||||
@@ -952,6 +956,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
@@ -987,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (!installedVersions.versions.has(latestVersion.version)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct || process.platform !== "linux") {
|
||||
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -508,14 +508,16 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
description:
|
||||
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
|
||||
"channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
validator: function(value) {
|
||||
if (!isTargetVersion(value)) {
|
||||
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
|
||||
throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
||||
import { levels } from "../logging";
|
||||
import { ControllerConfig, HostConfig } from "../config";
|
||||
import { ExternalFactorioVersionSchema } from "../external";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external";
|
||||
|
||||
export class ControllerStopRequest {
|
||||
declare ["constructor"]: typeof ControllerStopRequest;
|
||||
@@ -442,3 +442,25 @@ export class FactorioVersionsRequest {
|
||||
|
||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
||||
}
|
||||
|
||||
export class LatestReleasesRequest {
|
||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_latest_releases" as const;
|
||||
|
||||
constructor(
|
||||
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"maxAgeMs": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(LatestReleasesSchema);
|
||||
}
|
||||
@@ -88,16 +88,30 @@ export function integerPartialVersion(version: PartialVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
|
||||
* Matches a release channel name from the latest-releases API, such as
|
||||
* "stable" or "experimental". Excludes "latest", which is handled separately.
|
||||
* The set of channels is dynamic, so any lowercase identifier is accepted here
|
||||
* and resolved against the live API when the instance starts.
|
||||
*/
|
||||
const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export function isReleaseChannel(input: string): boolean {
|
||||
return input !== "latest" && releaseChannelRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions: a partial version, "latest", or a
|
||||
* release channel name (e.g. "stable" or "experimental").
|
||||
*/
|
||||
export type TargetVersion = PartialVersion | "latest";
|
||||
export const TargetVersionSchema = Type.Union([
|
||||
PartialVersionSchema,
|
||||
Type.Literal("latest"),
|
||||
Type.Unsafe<TargetVersion>(Type.String({ pattern: releaseChannelRegExp.source })),
|
||||
]);
|
||||
|
||||
export function isTargetVersion(input: string): input is TargetVersion {
|
||||
return input === "latest" || partialVersionRegExp.test(input);
|
||||
return input === "latest" || isReleaseChannel(input) || partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersion, FullVersionSchema } from "../data/version";
|
||||
|
||||
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
|
||||
|
||||
/**
|
||||
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
|
||||
* for a single release channel.
|
||||
*/
|
||||
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
|
||||
|
||||
/**
|
||||
* The latest released version of each build, keyed by release channel name
|
||||
* (e.g. "stable" and "experimental").
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
*
|
||||
* @returns The latest version of each build keyed by release channel
|
||||
* @throws Network errors and non-ok status responses
|
||||
*/
|
||||
export async function fetchLatestReleases(): Promise<LatestReleases> {
|
||||
let res;
|
||||
|
||||
try {
|
||||
res = await fetch(LATEST_RELEASES_URL);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return await res.json() as LatestReleases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a release channel to a concrete factorio version
|
||||
*
|
||||
* @param releases Releases data as returned by {@link fetchLatestReleases}
|
||||
* @param channel Release channel name, e.g. "stable" or "experimental"
|
||||
* @param build Build to read the version of, defaults to the headless build
|
||||
* @returns The version for the channel, or undefined if it cannot be resolved
|
||||
*/
|
||||
export function resolveReleaseChannel(
|
||||
releases: LatestReleases,
|
||||
channel: string,
|
||||
build = "headless",
|
||||
): FullVersion | undefined {
|
||||
const builds = releases[channel];
|
||||
if (!builds) {
|
||||
return undefined;
|
||||
}
|
||||
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
|
||||
}
|
||||
Vendored
+1
@@ -3,3 +3,4 @@
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
export * from "./LatestReleases";
|
||||
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
controller.LatestReleasesRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
|
||||
@@ -524,3 +524,9 @@ definePermission({
|
||||
title: "Get factorio versions",
|
||||
description: "Get list of all factorio versions from Wube",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.external.get_latest_releases",
|
||||
title: "Get factorio releases",
|
||||
description: "Get the latest stable and experimental factorio releases from Wube",
|
||||
});
|
||||
@@ -23,6 +23,7 @@ function InputVersion<
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
|
||||
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
|
||||
const [customVersion, setCustomVersion] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
@@ -39,6 +40,21 @@ function InputVersion<
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Release channels (e.g. stable, experimental) only apply to target versions
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) {
|
||||
const res = await control.latestReleases.get(5 * 60 * 1000);
|
||||
// Read the headless build's version directly rather than via a lib
|
||||
// helper: lib is a federation-shared module here and unused exports
|
||||
// can be tree-shaken out of it.
|
||||
setChannels(Object.entries(res).map(([name, builds]) => (
|
||||
{ name, version: builds.headless ?? Object.values(builds)[0] ?? "" }
|
||||
)));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
@@ -66,9 +82,17 @@ function InputVersion<
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" option for TargetVersion
|
||||
// Add "latest" and release channel options for TargetVersion
|
||||
if (props.version.includeLatest) {
|
||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||
tree.unshift(
|
||||
{ title: "latest", value: "latest", key: "latest", children: [] },
|
||||
...channels.map(({ name, version }) => ({
|
||||
title: version ? `${name} (${version})` : name,
|
||||
value: name,
|
||||
key: name,
|
||||
children: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return <TreeSelect
|
||||
|
||||
@@ -72,6 +72,9 @@ export class Control extends lib.Link {
|
||||
/** Cache for factorio versions to avoid repeat calls to the controller */
|
||||
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
|
||||
|
||||
/** Cache for the latest factorio release channels to avoid repeat calls to the controller */
|
||||
latestReleases = new lib.ValueCache(this.requestLatestReleases.bind(this));
|
||||
|
||||
declare connector: ControlConnector;
|
||||
|
||||
constructor(
|
||||
@@ -112,6 +115,10 @@ export class Control extends lib.Link {
|
||||
return this.send(new lib.FactorioVersionsRequest());
|
||||
}
|
||||
|
||||
requestLatestReleases() {
|
||||
return this.send(new lib.LatestReleasesRequest());
|
||||
}
|
||||
|
||||
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
|
||||
this.accountRoles = event.roles??null;
|
||||
this.emitAccountUpdate();
|
||||
|
||||
+6
-2
@@ -62,7 +62,11 @@ describe("host/server", function() {
|
||||
let installDir = path.join("test", "file", "factorio", "0.1.1");
|
||||
await assert.rejects(
|
||||
hostServer._findVersion(installDir, "0.1.2"),
|
||||
new Error("Unable to find Factorio version 0.1.2")
|
||||
new Error(
|
||||
`Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` +
|
||||
`install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -433,7 +437,7 @@ describe("host/server", function() {
|
||||
|
||||
assert.equal(fetchCalledWith, null);
|
||||
assert.ok(logLine !== null);
|
||||
assert.ok(logLine.endsWith("but must be manually downloaded"));
|
||||
assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux)."));
|
||||
});
|
||||
it("should do attempt to download on linux", async function() {
|
||||
let logLine = null;
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("lib/config/definitions/validators", function() {
|
||||
// invalid case
|
||||
assert.throws(
|
||||
() => config.set("factorio.version", "invalidValue"),
|
||||
/Value must be be 'latest', or match X.Y, or match X.Y.Z/
|
||||
/Value must be 'latest', a release channel, or match X.Y or X.Y.Z/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user