1
0
Fork 0

games/factorio: add modproxy

This adds a mod proxy system, called, well, modproxy.

It sits between Factorio server instances and the Factorio mod portal,
allowing for arbitrary mod download without needing the servers to know
Factorio credentials.

Change-Id: I7bc405a25b6f9559cae1f23295249f186761f212
master
q3k 2020-05-11 03:21:32 +02:00 committed by Serge Bazanski
parent 791ab6d1a5
commit 0581bbf8a0
12 changed files with 871 additions and 2 deletions

View File

@ -157,6 +157,7 @@ local kube = import "../../../kube/kube.libsonnet";
{ who: ["q3k", "informatic"], what: "go/svc/*" },
{ who: ["q3k"], what: "bgpwtf/*" },
{ who: ["q3k"], what: "devtools/*" },
{ who: ["q3k"], what: "games/factorio/*" },
{ who: ["q3k", "informatic"], what: "cluster/*" },
],
acl: [

View File

@ -0,0 +1,49 @@
load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "go_default_library",
srcs = ["main.go"],
importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy",
visibility = ["//visibility:private"],
deps = [
"//games/factorio/modproxy/modportal:go_default_library",
"//games/factorio/modproxy/proto:go_default_library",
"//go/mirko:go_default_library",
"@com_github_golang_glog//:go_default_library",
"@org_golang_google_grpc//codes:go_default_library",
"@org_golang_google_grpc//status:go_default_library",
],
)
go_binary(
name = "modproxy",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)
container_layer(
name = "layer_bin",
files = [
":modproxy",
"//games/factorio/modproxy/client:client",
],
directory = "/games/factorio/modproxy/",
)
container_image(
name = "runtime",
base = "@prodimage-bionic//image",
layers = [
":layer_bin",
],
)
container_push(
name = "push",
image = ":runtime",
format = "Docker",
registry = "registry.k0.hswaw.net",
repository = "games/factorio/modproxy",
tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
)

View File

@ -0,0 +1,38 @@
Factorio modproxy
=================
The modproxy is a microservice that caches Factorio mods.
Usually, Factorio mods from the mod portal need credentials in order to be downloaded. As nobody is willing to share their credentials, or buy a HSWAW factorio license, this proxy got implemented.
.-------------------. .----------. .----------.
| mods.factorio.com |<-------| modproxy |<-------------.---|.----------.
'-------------------' HTTP '----------' gRPC :----| factorio |
| Local | '| server |
| files | '----------'
V | .----------.
.-'''-. '----| Account |
|-___-| | holder |
| CAS | Cached mods '----------'
'-___-'
Factorio servers run a `client` binary that attempts to synchronize local mods with a specified intent of wanted Factorio mods. Any mods that are missing are downloaded from the modproxy over gRPC. Regardless of the success of the downloads, the modproxy client will then continue running the Factorio server.
The modproxy, when asked for a mod (via a `ModProxy.Download` gRPC call), will either serve it (if it has a copy of it), or record that this selected mod was missing and store this request in memory.
Then, when an Account Holder connects and calls `ModProxy.Mirror`, the modproxy will go through its saved list of pending mods to download, and use the credentials provided to download them.
Deployment
----------
Factorio servers and the modproxy live in the `factorio` namespace on k0. Factorio servers created via jsonnet will automatically spawn a modproxy client on startup that will attempt to download whatever mods havve been specified in the jsonnet configuration (which is serialized to `config.pb.text`).
Synchronizing Mods as an Account Holder
---------------------------------------
If you have a Factorio account, you can connect over to the modproxy to feed it any mods that it wanted to download but couldn't. Currently this is done via a manual grpcurl call, an admin client might be introduced at some later point:
kubectl -n factorio port-forward deployment/proxy 4200
grpcurl -plaintext -format text -d 'username: "q3k" token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"' 127.0.0.1:4200 modproxy.ModProxy.Mirror
The reuslt will be empty if no mods had to be synchronized.

View File

@ -0,0 +1,22 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "go_default_library",
srcs = ["client.go"],
importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/client",
visibility = ["//visibility:private"],
deps = [
"//games/factorio/modproxy/modportal:go_default_library",
"//games/factorio/modproxy/proto:go_default_library",
"//go/pki:go_default_library",
"@com_github_gogo_protobuf//proto:go_default_library",
"@com_github_golang_glog//:go_default_library",
"@org_golang_google_grpc//:go_default_library",
],
)
go_binary(
name = "client",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,164 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"code.hackerspace.pl/hscloud/go/pki"
"github.com/gogo/protobuf/proto"
"github.com/golang/glog"
"google.golang.org/grpc"
"code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal"
pb "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto"
)
func init() {
flag.Set("logtostderr", "true")
}
var (
flagProxy string
flagFactorioPath string
flagConfigPath string
)
func main() {
flag.StringVar(&flagProxy, "proxy", "modproxy.factorio.svc.k0.hswaw.net:4200", "Address of modproxy service")
flag.StringVar(&flagFactorioPath, "factorio_path", "", "Path to factorio server root")
flag.StringVar(&flagConfigPath, "config_path", "config.pb.text", "Path to client config file")
flag.Parse()
conn, err := grpc.Dial(flagProxy, pki.WithClientHSPKI())
if err != nil {
glog.Exitf("Dial(%q): %v", flagProxy, err)
return
}
if flagFactorioPath == "" {
glog.Exitf("factorio_path must be set")
}
if flagConfigPath == "" {
glog.Exitf("config_path must be set")
}
configBytes, err := ioutil.ReadFile(flagConfigPath)
if err != nil {
glog.Exitf("could not read config: %v", err)
}
configString := string(configBytes)
config := &pb.ClientConfig{}
err = proto.UnmarshalText(configString, config)
if err != nil {
glog.Exitf("could not parse config: %v", err)
}
ctx := context.Background()
proxy := pb.NewModProxyClient(conn)
// mod name -> wanted mod version
managed := make(map[string]string)
for _, m := range config.Mod {
modPath := fmt.Sprintf("%s/mods/%s_%s.zip", flagFactorioPath, m.Name, m.Version)
_, err := os.Stat(modPath)
if err == nil {
glog.Infof("Mod %s/%s up to date, skipping.", m.Name, m.Version)
continue
}
i, err := modportal.GetMod(ctx, m.Name)
if err != nil {
glog.Errorf("Could not fetch info about %s/%s: %v", m.Name, m.Version, err)
continue
}
release := i.ReleaseByVersion(m.Version)
if release == nil {
glog.Errorf("%s/%s: version does not exist!", m.Name, m.Version)
continue
}
glog.Infof("Trying to download %s/%s (%s)...", m.Name, m.Version, release.SHA1)
err = downloadMod(ctx, proxy, m.Name, release.SHA1, modPath)
if err != nil {
glog.Errorf("%s/%s: could not download mod: %v", m.Name, m.Version, err)
} else {
glog.Infof("Mod %s/%s downloaded.", m.Name, m.Version)
managed[m.Name] = m.Version
}
}
glog.Infof("Cleaning up old versions of managed mods...")
for mn, mv := range managed {
modPath := fmt.Sprintf("%s/mods/%s_%s.zip", flagFactorioPath, mn, mv)
modGlob := fmt.Sprintf("%s/mods/%s_*.zip", flagFactorioPath, mn)
matches, err := filepath.Glob(modGlob)
if err != nil {
glog.Errorf("Could not find old versions of %q: %v", mn, err)
continue
}
for _, m := range matches {
// skip managed version
if m == modPath {
continue
}
glog.Infof("Deleting old version: %s", m)
err := os.Remove(m)
if err != nil {
glog.Errorf("Could not remove old version %q: %v", m, err)
}
}
}
glog.Infof("Done!")
}
func downloadMod(ctx context.Context, proxy pb.ModProxyClient, modName, sha1, dest string) error {
req := &pb.DownloadRequest{
ModName: modName,
FileSha1: sha1,
}
stream, err := proxy.Download(ctx, req)
if err != nil {
return err
}
data := []byte{}
status := pb.DownloadResponse_STATUS_INVALID
for {
res, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
if res.Status != pb.DownloadResponse_STATUS_INVALID {
status = res.Status
}
data = append(data, res.Chunk...)
}
switch status {
case pb.DownloadResponse_STATUS_OKAY:
case pb.DownloadResponse_STATUS_NOT_AVAILABLE:
return fmt.Errorf("version not available on proxy")
default:
return fmt.Errorf("invalid download status: %v", status)
}
return ioutil.WriteFile(dest, data, 0644)
}

View File

@ -0,0 +1,298 @@
package main
import (
"context"
"crypto/sha1"
"encoding/hex"
"flag"
"fmt"
"io"
"os"
"regexp"
"strings"
"sync"
"time"
"code.hackerspace.pl/hscloud/go/mirko"
"github.com/golang/glog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal"
pb "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto"
)
func init() {
flag.Set("logtostderr", "true")
}
var (
flagCASDirectory string
)
func main() {
flag.StringVar(&flagCASDirectory, "cas_directory", "cas", "directory in which to store cached files")
flag.Parse()
m := mirko.New()
if err := m.Listen(); err != nil {
glog.Exitf("Listen(): %v", err)
}
srv := &service{
cache: make(map[string]*cacheEntry),
}
pb.RegisterModProxyServer(m.GRPC(), srv)
if err := m.Serve(); err != nil {
glog.Exitf("Serve(): %v", err)
}
<-m.Done()
}
var (
reSha1 = regexp.MustCompile(`^[a-f0-9]+$`)
)
func casPath(sha1 string) string {
sha1 = strings.ToLower(sha1)
if !reSha1.MatchString(sha1) {
return ""
}
return fmt.Sprintf("%s/%s", flagCASDirectory, sha1)
}
type service struct {
mu sync.Mutex
// cache of sha1 -> cache entry
cache map[string]*cacheEntry
}
type cacheEntry struct {
expires *time.Time
modName string
// found means that this is an entry confirmed on the mod portal
found bool
// mirrored means we are ready to serve this file to users
mirrored bool
}
func (s *service) Mirror(ctx context.Context, req *pb.MirrorRequest) (*pb.MirrorResponse, error) {
// build map of sha1->modName for needed downloads
modNames := make(map[string]string)
s.mu.Lock()
for sha, e := range s.cache {
if e == nil {
continue
}
if e.found == false {
continue
}
if e.mirrored == true {
continue
}
modNames[sha] = e.modName
}
s.mu.Unlock()
okays := make(map[string]bool)
errors := make(map[string]error)
for sha, modName := range modNames {
k := fmt.Sprintf("%s/%s", modName, sha)
mod, err := modportal.GetMod(ctx, modName)
if err != nil {
errors[k] = err
continue
}
release := mod.ReleaseBySHA1(sha)
if release == nil {
errors[k] = fmt.Errorf("could not find sha1 in modportal - deleted?")
continue
}
r, err := release.Download(ctx, req.Username, req.Token)
if err != nil {
errors[k] = fmt.Errorf("could not download: %v", err)
continue
}
path := casPath(sha)
pathIncoming := path + ".incoming"
out, err := os.Create(pathIncoming)
if err != nil {
errors[k] = fmt.Errorf("could not create file: %v", err)
continue
}
_, err = io.Copy(out, r)
if err != nil {
errors[k] = fmt.Errorf("could not save: %v", err)
continue
}
err = os.Rename(pathIncoming, path)
if err != nil {
errors[k] = fmt.Errorf("could not commit file: %v", err)
continue
}
okays[k] = true
s.cacheFeed(sha, modName, nil, true, true)
}
res := &pb.MirrorResponse{
ModsErrors: make(map[string]string),
}
for m, _ := range okays {
glog.Infof("Downloaded %q", m)
res.ModsOkay = append(res.ModsOkay, m)
}
for m, err := range errors {
glog.Errorf("Could not download %q: %v", m, err)
res.ModsErrors[m] = fmt.Sprintf("%v", err)
}
return res, nil
}
func (s *service) cacheGet(sha1 string) (hit, found, mirrored bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.cache[sha1]
if !ok || entry == nil {
return
}
if entry.expires != nil && time.Now().Before(*entry.expires) {
delete(s.cache, sha1)
return
}
hit = true
found = entry.found
mirrored = entry.mirrored
return
}
func (s *service) cacheFeed(sha1, modName string, expires *time.Time, found, mirrored bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.cache[sha1] = &cacheEntry{
expires: expires,
modName: modName,
found: found,
mirrored: mirrored,
}
}
func (s *service) serve(req *pb.DownloadRequest, srv pb.ModProxy_DownloadServer) error {
cas := casPath(req.FileSha1)
if cas == "" {
// Invalid sha1? Fail.
return status.Error(codes.Aborted, "invalid sha1")
}
file, err := os.Open(cas)
if err != nil {
// not in CAS, update cache and fail
s.cacheFeed(req.FileSha1, req.ModName, nil, true, false)
return srv.Send(&pb.DownloadResponse{
Status: pb.DownloadResponse_STATUS_NOT_AVAILABLE,
})
}
defer file.Close()
err = srv.Send(&pb.DownloadResponse{
Status: pb.DownloadResponse_STATUS_OKAY,
})
if err != nil {
return err
}
buf := make([]byte, 1024*1024)
hash := sha1.New()
for {
n, err := file.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return status.Errorf(codes.Unavailable, "error reading file: %v", err)
}
hash.Write(buf[:n])
err = srv.Send(&pb.DownloadResponse{
Chunk: buf[:n],
})
if err != nil {
return err
}
}
// entire file send, double-check shasum
sum := hex.EncodeToString(hash.Sum(nil))
if sum != req.FileSha1 {
glog.Errorf("CAS corruption: wanted %q, got %q", req.FileSha1, sum)
return status.Error(codes.Aborted, "CAS corruption")
}
return nil
}
func (s *service) Download(req *pb.DownloadRequest, srv pb.ModProxy_DownloadServer) error {
ctx := srv.Context()
modName := req.ModName
if modName == "" {
return status.Error(codes.InvalidArgument, "mod name must be set")
}
sha1 := req.FileSha1
if sha1 == "" {
return status.Error(codes.InvalidArgument, "sha1 must be set")
}
sha1 = strings.ToLower(sha1)
req.FileSha1 = sha1
cacheHit, found, mirrored := s.cacheGet(sha1)
if cacheHit {
if !found {
return status.Error(codes.NotFound, "sha1 not found for mod")
}
if !mirrored {
return srv.Send(&pb.DownloadResponse{
Status: pb.DownloadResponse_STATUS_NOT_AVAILABLE,
})
}
// we have the file, serve it
return s.serve(req, srv)
}
// cache not hit, check mod portal
mod, err := modportal.GetMod(ctx, modName)
if err != nil {
return err
}
release := mod.ReleaseBySHA1(sha1)
// release not found in mod portal, cache and answer
if release == nil {
expires := time.Now().Add(1 * time.Minute)
s.cacheFeed(sha1, modName, &expires, false, false)
return status.Error(codes.InvalidArgument, "sha1 not found for mod")
}
// we assume it's mirrored - the first cas serve will prove us wrong otherwise and
// update the cache.
s.cacheFeed(sha1, modName, nil, true, true)
// call ourselves again now that the cache is fed. computers - it's like magic!
return s.Download(req, srv)
}

View File

@ -0,0 +1,12 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = ["modportal.go"],
importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/modportal",
visibility = ["//visibility:public"],
deps = [
"@org_golang_google_grpc//codes:go_default_library",
"@org_golang_google_grpc//status:go_default_library",
],
)

View File

@ -0,0 +1,98 @@
package modportal
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Mod struct {
Name string `json:"name"`
Releases []Release `json:"releases"`
}
type Release struct {
DownloadURL string `json:"download_url"`
FileName string `json:"file_name"`
Info InfoJSON `json:"info_json"`
ReleasedAt string `json:"released_at"`
SHA1 string `json:"sha1"`
Version string `json:"version"`
}
type InfoJSON struct {
Dependencies []string `json:"dependencies"`
FactorioVersion string `json:"factorio_json"`
}
func GetMod(ctx context.Context, name string) (*Mod, error) {
url := fmt.Sprintf("https://mods.factorio.com/api/mods/%s", url.PathEscape(name))
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "NewRequest(%q): %v", url, err)
}
req = req.WithContext(ctx)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "Do(req{%q}): %v", url, err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, status.Errorf(codes.Unavailable, "mod portal responded with code: %d", res.StatusCode)
}
mod := &Mod{}
err = json.NewDecoder(res.Body).Decode(mod)
if err != nil {
return nil, status.Errorf(codes.Internal, "could not decode mod portal JSON: %v", err)
}
return mod, nil
}
func (m *Mod) ReleaseBySHA1(sha1 string) *Release {
for _, r := range m.Releases {
if r.SHA1 == sha1 {
return &r
}
}
return nil
}
func (m *Mod) ReleaseByVersion(version string) *Release {
for _, r := range m.Releases {
if r.Version == version {
return &r
}
}
return nil
}
func (r *Release) Download(ctx context.Context, username, token string) (io.ReadCloser, error) {
url := fmt.Sprintf("https://mods.factorio.com/%s?username=%s&token=%s", r.DownloadURL, username, token)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, status.Errorf(codes.Internal, "NewRequest(%q): %v", url, err)
}
req = req.WithContext(ctx)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "Do(req{%q}): %v", url, err)
}
if res.StatusCode != 200 {
res.Body.Close()
return nil, status.Errorf(codes.Unavailable, "mod portal responded with code: %d", res.StatusCode)
}
return res.Body, err
}

View File

@ -0,0 +1,23 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
proto_library(
name = "proto_proto",
srcs = ["modproxy.proto"],
visibility = ["//visibility:public"],
)
go_proto_library(
name = "proto_go_proto",
compilers = ["@io_bazel_rules_go//proto:go_grpc"],
importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto",
proto = ":proto_proto",
visibility = ["//visibility:public"],
)
go_library(
name = "go_default_library",
embed = [":proto_go_proto"],
importpath = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto",
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,43 @@
syntax = "proto3";
package modproxy;
option go_package = "code.hackerspace.pl/hscloud/games/factorio/modproxy/proto";
service ModProxy {
rpc Mirror(MirrorRequest) returns (MirrorResponse);
rpc Download(DownloadRequest) returns (stream DownloadResponse);
}
message MirrorRequest {
// Authentication details to access the mod portal.
string username = 1;
string token = 2;
}
message MirrorResponse {
repeated string mods_okay = 1;
map<string, string> mods_errors = 2;
}
message DownloadRequest {
string mod_name = 1;
string file_sha1 = 2;
}
message DownloadResponse {
enum Status {
STATUS_INVALID = 0;
STATUS_OKAY = 1;
STATUS_NOT_AVAILABLE = 2;
};
Status status = 1;
bytes chunk = 2;
}
// Configuration for client (in text proto, so singular names in repeated fields)
message ClientConfig {
message Mod {
string name = 1;
string version = 2;
}
repeated Mod mod = 1;
}

View File

@ -11,6 +11,7 @@ local kube = import "../../../../kube/kube.libsonnet";
appName: "factorio",
storageClassName: "waw-hdd-redundant-2",
prefix: "", # if set, should be 'foo-'
proxyImage: error "proxyImage must be set",
rconPort: 2137,
rconPassword: "farts",
@ -27,6 +28,8 @@ local kube = import "../../../../kube/kube.libsonnet";
memory: "1Gi",
},
},
mods: [],
},
@ -67,6 +70,16 @@ local kube = import "../../../../kube/kube.libsonnet";
},
},
configMap: kube.ConfigMap(factorio.makeName("config")) {
metadata+: factorio.metadata,
data: {
"mods.pb.text": std.join("\n", [
"mod { name: \"%s\" version: \"%s\" }" % [m.name, m.version],
for m in cfg.mods
]),
},
},
deployment: kube.Deployment(factorio.makeName("factorio")) {
metadata+: factorio.metadata,
spec+: {
@ -76,6 +89,23 @@ local kube = import "../../../../kube/kube.libsonnet";
volumes_: {
data: kube.PersistentVolumeClaimVolume(factorio.volumeClaimData),
mods: kube.PersistentVolumeClaimVolume(factorio.volumeClaimMods),
config: kube.ConfigMapVolume(factorio.configMap),
},
initContainers_: {
modproxy: kube.Container("modproxy") {
image: cfg.proxyImage,
command: [
"/games/factorio/modproxy/client",
"-hspki_disable",
"-factorio_path", "/factorio",
"-proxy", "proxy.factorio.svc.cluster.local:4200",
"-config_path", "/factorio/mods.pb.text",
],
volumeMounts_: {
mods: { mountPath: "/factorio/mods" },
config: { mountPath: "/factorio/mods.pb.text", subPath: "mods.pb.text" },
},
},
},
containers_: {
factorio: kube.Container(factorio.makeName("factorio")) {

View File

@ -9,15 +9,106 @@ local kube = import "../../../../kube/kube.libsonnet";
{
local prod = self,
proxyImage:: "registry.k0.hswaw.net/games/factorio/modproxy:1589157915-eafe7be328477e8a6590c4210466ef12901f1b9a",
namespace: kube.Namespace("factorio"),
instance(name, tag):: factorio {
cfg+: {
namespace: "factorio",
prefix: name + "-",
tag: tag,
proxyImage: prod.proxyImage,
}
},
q3k: prod.instance("q3k", "1.0.0-1"),
pymods: prod.instance("pymods", "1.0.0-1"),
proxy: {
pvc: kube.PersistentVolumeClaim("proxy-cas") {
metadata+: {
namespace: "factorio",
},
spec+: {
storageClassName: "waw-hdd-redundant-3",
accessModes: [ "ReadWriteOnce" ],
resources: {
requests: {
storage: "32Gi",
},
},
},
},
deploy: kube.Deployment("proxy") {
metadata+: {
namespace: "factorio",
},
spec+: {
template+: {
spec+: {
volumes_: {
cas: kube.PersistentVolumeClaimVolume(prod.proxy.pvc),
},
containers_: {
proxy: kube.Container("proxy") {
image:prod.proxyImage,
command: [
"/games/factorio/modproxy/modproxy",
"-hspki_disable",
"-cas_directory", "/mnt/cas",
"-listen_address", "0.0.0.0:4200",
],
volumeMounts_: {
cas: { mountPath: "/mnt/cas" },
},
ports_: {
client: { containerPort: 4200 },
},
},
},
},
},
},
},
svc: kube.Service("proxy") {
metadata+: {
namespace: "factorio",
},
target_pod:: prod.proxy.deploy.spec.template,
spec+: {
ports: [
{ name: "client", port: 4200, targetPort: 4200, protocol: "TCP" },
],
},
},
},
local mod = function(name, version) { name: name, version: version },
q3k: prod.instance("q3k", "1.0.0-1") {
cfg+: {
mods: [
mod("Squeak Through", "1.8.0"),
mod("Bottleneck", "0.11.4"),
],
},
},
pymods: prod.instance("pymods", "1.0.0-1") {
cfg+: {
mods: [
mod("Bottleneck", "0.11.4"),
mod("FARL", "4.0.2"),
mod("Squeak Through", "1.8.0"),
mod("pycoalprocessing", "1.8.3"),
mod("pycoalprocessinggraphics", "1.0.7"),
mod("pyfusionenergy", "1.6.3"),
mod("pyfusionenergygraphics", "1.0.5"),
mod("pyhightech", "1.6.2"),
mod("pyhightechgraphics", "1.0.8"),
mod("pyindustry", "1.4.7"),
mod("pyrawores", "2.1.5"),
mod("pyraworesgraphics", "1.0.4"),
mod("rso-mod", "6.0.11"),
mod("stdlib", "1.4.3"),
mod("what-is-it-really-used-for", "1.5.13"),
],
},
},
}