Merge pull request 'feat(deploy): allow configurable host ports' (#46) from codex/block-18b-host-port-selection into main
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
Reviewed-on: #46 Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #46.
This commit is contained in:
@@ -27,6 +27,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
|
||||
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-logo/artwork routes.
|
||||
- Administration stores persistent game-container UID/GID defaults (1000:1000) with decimal uint32 validation. Managed templates use them as Docker `User`; `user_mode: image` is authoritative and omits Docker `User`. Templates can map the values to declared runtime environment variables; V Rising uses `PUID`/`PGID` while retaining its root entrypoint and capabilities.
|
||||
- Deployment forms expose published template ports as distinct host-port inputs. Host bindings default to the template container port, remain persisted in the instance preview, validate decimal range and same-protocol uniqueness, and never publish `publish: false` ports.
|
||||
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
|
||||
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
|
||||
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
|
||||
@@ -80,6 +81,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
|
||||
- V Rising is the first template-scoped TCP RCON module. Its manifest currently declares only verified connectivity/status; command operations are not advertised until validated end-to-end against a real server. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0.
|
||||
- Module TCP access is instance-scoped and template-bound: the guest supplies no destination, only bounded bytes; the host pins the instance network address and declared integration port, enforces deadlines and response limits, and rejects arbitrary/unsafe destinations.
|
||||
- Published port selection is generic: templates own container ports and protocols, while administrators choose host ports at deployment; TCP and UDP may reuse a host number because Docker treats those bindings independently.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
|
||||
@@ -240,7 +240,10 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
|
||||
knownPorts := make(map[string]struct{}, len(snapshot.Template.Container.Ports))
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
knownPorts[port.ID] = struct{}{}
|
||||
hostPort := request.HostPorts[port.ID]
|
||||
hostPort, provided := request.HostPorts[port.ID]
|
||||
if port.Publish && !provided {
|
||||
hostPort = port.ContainerPort
|
||||
}
|
||||
if port.Publish && (hostPort < 1 || hostPort > 65535) {
|
||||
return Preview{}, fmt.Errorf("published port %s requires a valid host port", port.ID)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestBuildPreviewUsesManagedIdentityAndVRisingEnvironmentMapping(t *testing.
|
||||
}
|
||||
vrising := snapshotByID(t, snapshots, "vrising-didstopia")
|
||||
identity := instance.RuntimeIdentity{UID: 1234, GID: 5678}
|
||||
preview, err := instance.BuildPreview(vrising, instance.PreviewRequest{DisplayName: "V Rising", HostPorts: map[string]int{"game": 38000, "query": 38001}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
|
||||
preview, err := instance.BuildPreview(vrising, instance.PreviewRequest{DisplayName: "V Rising", HostPorts: map[string]int{"game": 45230, "query": 45231}, MountPaths: map[string]string{"persistent": filepath.Join(t.TempDir(), "persistent"), "server": filepath.Join(t.TempDir(), "server")}, DataOrigin: "new", BackupRetention: 7, RuntimeIdentity: &identity})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -92,6 +92,9 @@ func TestBuildPreviewUsesManagedIdentityAndVRisingEnvironmentMapping(t *testing.
|
||||
if len(plan.CapAdd) != 5 {
|
||||
t.Fatalf("V Rising capabilities changed: %#v", plan.CapAdd)
|
||||
}
|
||||
if len(plan.Ports) != 3 || plan.Ports[0].HostPort != 45230 || plan.Ports[0].ContainerPort != 9876 || plan.Ports[1].HostPort != 45231 || plan.Ports[1].ContainerPort != 9877 || plan.Ports[2].HostPort != 0 || plan.Ports[2].ContainerPort != 9878 {
|
||||
t.Fatalf("V Rising port bindings = %#v", plan.Ports)
|
||||
}
|
||||
managed := vrising
|
||||
managed.Template.Container.UserMode = instance.DockerUserDoGaMa
|
||||
managed.Template.Container.RuntimeUser.Mode = "docker"
|
||||
@@ -105,6 +108,37 @@ func TestBuildPreviewUsesManagedIdentityAndVRisingEnvironmentMapping(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewDefaultsPublishedHostPortsAndAllowsTCPUDPReuse(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
palworld := snapshotByID(t, snapshots, "palworld-official")
|
||||
preview, err := instance.BuildPreview(palworld, instance.PreviewRequest{DisplayName: "Default ports", MountPaths: map[string]string{"saved": "/srv/game-servers/default-ports/saved"}, DataOrigin: "new", BackupRetention: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
|
||||
if err != nil || len(plan.Ports) != 2 || plan.Ports[0].HostPort != 8211 || plan.Ports[1].HostPort != 0 {
|
||||
t.Fatalf("default port bindings = %#v error=%v", plan.Ports, err)
|
||||
}
|
||||
|
||||
vrising := snapshotByID(t, snapshots, "vrising-didstopia")
|
||||
vrising.Template.Container.Ports[1].Protocol = "tcp"
|
||||
request := instance.PreviewRequest{DisplayName: "Reuse protocols", HostPorts: map[string]int{"game": 45230, "query": 45230}, MountPaths: map[string]string{"persistent": "/srv/game-servers/reuse-protocols/persistent", "server": "/srv/game-servers/reuse-protocols/server"}, DataOrigin: "new", BackupRetention: 7}
|
||||
if _, err := instance.BuildPreview(vrising, request); err != nil {
|
||||
t.Fatalf("same TCP/UDP host port rejected: %v", err)
|
||||
}
|
||||
request.HostPorts["query"] = 45231
|
||||
if _, err := instance.BuildPreview(vrising, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.HostPorts["game"] = 0
|
||||
if _, err := instance.BuildPreview(vrising, request); err == nil {
|
||||
t.Fatal("missing published host port unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,10 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
if state != "draft" || digest != palworld.Digest {
|
||||
t.Fatalf("draft state=%q digest=%q", state, digest)
|
||||
}
|
||||
stored, err := repository.GetInstance(ctx, "opaque-instance-id")
|
||||
if err != nil || len(stored.Preview.Ports) == 0 || stored.Preview.Ports[0].HostPort != 8211 {
|
||||
t.Fatalf("stored host port bindings = %#v error=%v", stored.Preview.Ports, err)
|
||||
}
|
||||
|
||||
if _, err := repository.BeginOperation(
|
||||
ctx,
|
||||
|
||||
@@ -15,7 +15,7 @@ var messages = map[string]map[string]string{
|
||||
"login.title": "Sign in", "login.heading": "Sign in", "login.introduction": "Manage your game servers from one secure place.", "login.submit": "Sign in", "logout.submit": "Sign out",
|
||||
"dashboard.title": "Dashboard", "dashboard.eyebrow": "Dashboard", "dashboard.heading": "Game servers", "dashboard.introduction": "Overview of all your game server instances.", "dashboard.search": "Search instances", "dashboard.search_placeholder": "Search instance", "dashboard.summary": "Instance summary", "dashboard.total": "Total instances", "dashboard.running": "Running", "dashboard.stopped": "Stopped", "dashboard.updating": "Updating", "dashboard.error": "Error", "dashboard.no_match": "No matching instance", "dashboard.empty_heading": "No instances deployed", "dashboard.empty_copy": "Your game servers will appear here when they are added from the Catalog.", "dashboard.instances_eyebrow": "Servers", "dashboard.instances": "Your instances", "dashboard.activity_eyebrow": "Operations", "dashboard.recent_activity": "Recent activity", "dashboard.no_activity": "No recent activity", "dashboard.system_eyebrow": "Health", "dashboard.system_status": "System status", "dashboard.agent": "Docker agent", "dashboard.database": "Database", "dashboard.storage": "Storage", "dashboard.backups": "Backups", "dashboard.audit_log": "Audit log", "dashboard.online": "Online", "dashboard.offline": "Offline", "dashboard.healthy": "Healthy", "dashboard.unavailable": "Unavailable", "dashboard.last_backup": "Last backup", "dashboard.no_backup": "No backup", "dashboard.retention_days": "days retention", "dashboard.unlimited": "Unlimited retention", "dashboard.by": "by",
|
||||
"catalog.title": "Catalog", "catalog.eyebrow": "Game library", "catalog.heading": "Catalog", "catalog.search": "Search games", "catalog.search_placeholder": "Search a game", "catalog.scan": "Scan", "catalog.found": "templates found", "catalog.valid": "valid", "catalog.invalid": "invalid", "catalog.no_match": "No matching game", "catalog.empty": "No game available", "catalog.empty_admin": "Add templates to /var/lib/dogama/templates, then use Scan.", "catalog.back": "Back to catalog", "catalog.minimum": "Minimum", "catalog.recommended": "Recommended", "catalog.memory": "Memory", "catalog.storage": "Storage", "catalog.other": "Other", "catalog.deploy": "Deploy", "catalog.deploy_unavailable": "Deployment will be available in the next milestone.",
|
||||
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
|
||||
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.network": "Network ports", "deployment.network_help": "Host ports publish the template's internal ports. Private ports are not published.", "deployment.host_port": "Host port", "deployment.container_port": "Container port", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
|
||||
"settings.game_runtime": "Game server execution", "settings.game_runtime_help": "These defaults apply only to game-server containers.", "settings.game_runtime_uid": "Default UID", "settings.game_runtime_gid": "Default GID", "settings.game_runtime_policy_help": "Templates that allow an administered identity use these values. Templates using the image's native user ignore them.", "settings.save_game_runtime": "Save game server identity",
|
||||
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
|
||||
"settings.channels": "Notification channels", "settings.channels_help": "Secrets remain encrypted and are never displayed after saving.", "settings.no_channels": "No channel configured.", "settings.send_test": "Send test", "settings.delete": "Delete", "settings.add_channel": "Add channel", "settings.name": "Name", "settings.type": "Type", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.events": "Events (space separated)", "settings.audit_retention": "Audit retention", "settings.audit_help": "Control history size and perform explicit bounded purges.", "settings.view_audit": "View audit events", "settings.retention_days": "Retention days", "settings.maximum_entries": "Maximum entries", "settings.zero_unlimited": "Zero means unlimited and may grow the database indefinitely.", "settings.save_retention": "Save retention", "settings.delete_before": "Delete events before", "settings.confirm_purge": "Confirm bounded audit purge", "settings.purge": "Purge audit events", "settings.container_labels": "Game-container labels", "settings.container_labels_help": "These labels apply only to game-server containers.", "settings.global_labels": "Global labels", "settings.apply": "Application", "settings.next_start": "Apply on next start", "settings.immediate": "Apply immediately", "settings.disconnection": "Immediate application stops and recreates affected containers.", "settings.confirm_disconnection": "I understand the immediate-disconnection warning", "settings.save_labels": "Save game-container labels",
|
||||
@@ -30,7 +30,7 @@ var messages = map[string]map[string]string{
|
||||
"login.title": "Connexion", "login.heading": "Se connecter", "login.introduction": "Gérez vos serveurs de jeux depuis un espace sécurisé.", "login.submit": "Se connecter", "logout.submit": "Se déconnecter",
|
||||
"dashboard.title": "Tableau de bord", "dashboard.eyebrow": "Tableau de bord", "dashboard.heading": "Serveurs de jeux", "dashboard.introduction": "Vue d'ensemble de toutes vos instances de serveurs de jeux.", "dashboard.search": "Rechercher des instances", "dashboard.search_placeholder": "Rechercher une instance", "dashboard.summary": "Résumé des instances", "dashboard.total": "Instances totales", "dashboard.running": "En cours", "dashboard.stopped": "Arrêtées", "dashboard.updating": "Mise à jour", "dashboard.error": "Erreur", "dashboard.no_match": "Aucune instance correspondante", "dashboard.empty_heading": "Aucune instance déployée", "dashboard.empty_copy": "Vos serveurs de jeux apparaîtront ici lorsqu'ils seront ajoutés depuis le Catalogue.", "dashboard.instances_eyebrow": "Serveurs", "dashboard.instances": "Vos instances", "dashboard.activity_eyebrow": "Opérations", "dashboard.recent_activity": "Activité récente", "dashboard.no_activity": "Aucune activité récente", "dashboard.system_eyebrow": "Santé", "dashboard.system_status": "État du système", "dashboard.agent": "Agent Docker", "dashboard.database": "Base de données", "dashboard.storage": "Stockage", "dashboard.backups": "Sauvegardes", "dashboard.audit_log": "Journal d'audit", "dashboard.online": "En ligne", "dashboard.offline": "Hors ligne", "dashboard.healthy": "Saine", "dashboard.unavailable": "Indisponible", "dashboard.last_backup": "Dernière sauvegarde", "dashboard.no_backup": "Aucune sauvegarde", "dashboard.retention_days": "jours de rétention", "dashboard.unlimited": "Rétention illimitée", "dashboard.by": "par",
|
||||
"catalog.title": "Catalogue", "catalog.eyebrow": "Bibliothèque de jeux", "catalog.heading": "Catalogue", "catalog.search": "Rechercher des jeux", "catalog.search_placeholder": "Rechercher un jeu", "catalog.scan": "Scanner", "catalog.found": "templates trouvés", "catalog.valid": "valides", "catalog.invalid": "invalides", "catalog.no_match": "Aucun jeu correspondant", "catalog.empty": "Aucun jeu disponible", "catalog.empty_admin": "Ajoutez des templates dans /var/lib/dogama/templates, puis utilisez Scanner.", "catalog.back": "Retour au catalogue", "catalog.minimum": "Minimum", "catalog.recommended": "Recommandé", "catalog.memory": "Mémoire", "catalog.storage": "Stockage", "catalog.other": "Autre", "catalog.deploy": "Déployer", "catalog.deploy_unavailable": "Le déploiement sera disponible au prochain bloc.",
|
||||
"deployment.title": "Déployer", "deployment.eyebrow": "Nouvelle instance", "deployment.heading": "Configurer votre serveur", "deployment.name": "Nom de l’instance", "deployment.description": "Description de l’instance", "deployment.parameters": "Paramètres du jeu", "deployment.backup": "Importer une sauvegarde externe", "deployment.backup_help": "Les archives ZIP, TAR, TAR.GZ et TAR.ZST sont validées avant utilisation.", "deployment.go": "Go",
|
||||
"deployment.title": "Déployer", "deployment.eyebrow": "Nouvelle instance", "deployment.heading": "Configurer votre serveur", "deployment.name": "Nom de l’instance", "deployment.description": "Description de l’instance", "deployment.network": "Ports réseau", "deployment.network_help": "Les ports hôte publient les ports internes du template. Les ports privés ne sont pas publiés.", "deployment.host_port": "Port hôte", "deployment.container_port": "Port du conteneur", "deployment.parameters": "Paramètres du jeu", "deployment.backup": "Importer une sauvegarde externe", "deployment.backup_help": "Les archives ZIP, TAR, TAR.GZ et TAR.ZST sont validées avant utilisation.", "deployment.go": "Go",
|
||||
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
|
||||
"settings.channels": "Canaux de notification", "settings.channels_help": "Les secrets restent chiffrés et ne sont jamais affichés après enregistrement.", "settings.no_channels": "Aucun canal configuré.", "settings.send_test": "Envoyer un test", "settings.delete": "Supprimer", "settings.add_channel": "Ajouter un canal", "settings.name": "Nom", "settings.type": "Type", "settings.enabled": "activé", "settings.disabled": "désactivé", "settings.events": "Événements (séparés par des espaces)", "settings.audit_retention": "Rétention de l'audit", "settings.audit_help": "Contrôlez la taille de l'historique et effectuez des purges limitées explicites.", "settings.view_audit": "Voir les événements d'audit", "settings.retention_days": "Jours de rétention", "settings.maximum_entries": "Nombre maximal d'entrées", "settings.zero_unlimited": "Zéro signifie illimité et peut faire croître la base indéfiniment.", "settings.save_retention": "Enregistrer la rétention", "settings.delete_before": "Supprimer les événements antérieurs au", "settings.confirm_purge": "Confirmer la purge limitée de l'audit", "settings.purge": "Purger les événements d'audit", "settings.container_labels": "Étiquettes des conteneurs de jeux", "settings.container_labels_help": "Ces étiquettes s'appliquent uniquement aux conteneurs de serveurs de jeux.", "settings.global_labels": "Étiquettes globales", "settings.apply": "Application", "settings.next_start": "Appliquer au prochain démarrage", "settings.immediate": "Appliquer immédiatement", "settings.disconnection": "L'application immédiate arrête et recrée les conteneurs concernés.", "settings.confirm_disconnection": "Je comprends l'avertissement de déconnexion immédiate", "settings.save_labels": "Enregistrer les étiquettes des conteneurs",
|
||||
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Consultez les événements importants d'authentification et de modification.", "audit.actor": "ID de l'acteur", "audit.instance": "ID de l'instance", "audit.action": "Action", "audit.outcome": "Résultat", "audit.any": "Tous", "audit.allowed": "Autorisé", "audit.denied": "Refusé", "audit.failed": "Échec", "audit.filter": "Filtrer l'audit", "audit.time": "Heure", "audit.actor_column": "Acteur", "audit.instance_column": "Instance", "audit.empty": "Aucun événement d'audit ne correspond à ces filtres.",
|
||||
|
||||
+48
-12
@@ -153,10 +153,11 @@ type instanceDetailPage struct {
|
||||
}
|
||||
|
||||
type deploymentPage struct {
|
||||
Template *catalog.Template
|
||||
Values map[string]string
|
||||
Error string
|
||||
Success bool
|
||||
Template *catalog.Template
|
||||
Values map[string]string
|
||||
HostPorts map[string]string
|
||||
Error string
|
||||
Success bool
|
||||
}
|
||||
|
||||
type dashboardActivity struct {
|
||||
@@ -1853,10 +1854,44 @@ func (s *server) deploymentPage(w http.ResponseWriter, r *http.Request) {
|
||||
values[field.ID] = fmt.Sprint(field.Default)
|
||||
}
|
||||
}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values, HostPorts: deploymentHostPortValues(snapshot.Template)}
|
||||
s.render(w, http.StatusOK, "deployment.html", data)
|
||||
}
|
||||
|
||||
func deploymentHostPortValues(template catalog.Template) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, port := range template.Container.Ports {
|
||||
if port.Publish {
|
||||
values[port.ID] = strconv.Itoa(port.ContainerPort)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func parseDeploymentHostPorts(r *http.Request, template catalog.Template) (map[string]int, map[string]string, error) {
|
||||
hostPorts := map[string]int{}
|
||||
displayValues := deploymentHostPortValues(template)
|
||||
used := map[string]string{}
|
||||
for _, port := range template.Container.Ports {
|
||||
if !port.Publish {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(r.FormValue("host_port_" + port.ID))
|
||||
displayValues[port.ID] = value
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 65535 {
|
||||
return nil, displayValues, fmt.Errorf("host port for %s must be an integer between 1 and 65535", port.ID)
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d", port.Protocol, parsed)
|
||||
if previous, exists := used[key]; exists {
|
||||
return nil, displayValues, fmt.Errorf("host port %d/%s is used by both %s and %s", parsed, port.Protocol, previous, port.ID)
|
||||
}
|
||||
used[key] = port.ID
|
||||
hostPorts[port.ID] = parsed
|
||||
}
|
||||
return hostPorts, displayValues, nil
|
||||
}
|
||||
|
||||
func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
data, snapshot, ok := s.deploymentData(w, r)
|
||||
if !ok {
|
||||
@@ -1890,15 +1925,16 @@ func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
values[field.ID] = r.FormValue(name)
|
||||
}
|
||||
}
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
||||
hostPorts, displayHostPorts, hostPortErr := parseDeploymentHostPorts(r, snapshot.Template)
|
||||
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values, HostPorts: displayHostPorts}
|
||||
if hostPortErr != nil {
|
||||
data.Deployment.Error = hostPortErr.Error()
|
||||
s.render(w, http.StatusUnprocessableEntity, "deployment.html", data)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("display_name"))
|
||||
slug := instance.Slugify(name)
|
||||
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: map[string]int{}, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
if port.Publish {
|
||||
request.HostPorts[port.ID] = port.ContainerPort
|
||||
}
|
||||
}
|
||||
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: hostPorts, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
|
||||
for _, mount := range snapshot.Template.Storage.Mounts {
|
||||
request.MountPaths[mount.ID] = filepath.Join(s.serversRoot, slug, mount.ID)
|
||||
}
|
||||
|
||||
@@ -1166,7 +1166,7 @@ func TestDeploymentFormRequiresAdminAndRendersTemplateFields(t *testing.T) {
|
||||
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
||||
page := request(t, handler, http.MethodGet, "/catalog/palworld-official/deploy", adminCookies)
|
||||
assertStatus(t, page, http.StatusOK)
|
||||
for _, expected := range []string{"server_name", "max_players", "admin_password", "DoGaMa Palworld Server"} {
|
||||
for _, expected := range []string{"server_name", "max_players", "admin_password", "host_port_game", "Container port", "DoGaMa Palworld Server"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("deployment form missing %q", expected)
|
||||
}
|
||||
@@ -1179,6 +1179,36 @@ func TestDeploymentFormRequiresAdminAndRendersTemplateFields(t *testing.T) {
|
||||
|
||||
func TestDeploymentHTTPAsyncProgressAndRBAC(t *testing.T) { testDeploymentHTTPAsync(t, false) }
|
||||
|
||||
func TestParseDeploymentHostPortsValidatesPublishedBindings(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := snapshots[0].Template
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=45230"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
hostPorts, display, err := parseDeploymentHostPorts(req, template)
|
||||
if err != nil || hostPorts["game"] != 45230 || display["game"] != "45230" {
|
||||
t.Fatalf("host ports=%#v display=%#v error=%v", hostPorts, display, err)
|
||||
}
|
||||
var vrising catalog.Template
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot.Template.ID == "vrising-didstopia" {
|
||||
vrising = snapshot.Template
|
||||
}
|
||||
}
|
||||
duplicate := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=45230&host_port_query=45230"))
|
||||
duplicate.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseDeploymentHostPorts(duplicate, vrising); err == nil {
|
||||
t.Fatal("duplicate UDP host ports unexpectedly accepted")
|
||||
}
|
||||
invalid := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("host_port_game=0"))
|
||||
invalid.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseDeploymentHostPorts(invalid, template); err == nil {
|
||||
t.Fatal("invalid host port unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentWorkerPanicFailsOperation(t *testing.T) { testDeploymentHTTPAsync(t, true) }
|
||||
|
||||
func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
@@ -1221,7 +1251,7 @@ func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
}
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
for key, value := range map[string]string{"csrf_token": admin.CSRFToken, "display_name": "Async test", "description": "safe", "config_server_name": "Async", "config_server_description": "safe", "config_max_players": "16", "config_admin_password": "top-secret-value", "config_rest_api_enabled": "true", "config_rest_api_port": "8212"} {
|
||||
for key, value := range map[string]string{"csrf_token": admin.CSRFToken, "display_name": "Async test", "description": "safe", "host_port_game": "45230", "config_server_name": "Async", "config_server_description": "safe", "config_max_players": "16", "config_admin_password": "top-secret-value", "config_rest_api_enabled": "true", "config_rest_api_port": "8212"} {
|
||||
_ = form.WriteField(key, value)
|
||||
}
|
||||
_ = form.Close()
|
||||
@@ -1237,6 +1267,10 @@ func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &accepted); err != nil || accepted["operation_id"] == "" {
|
||||
t.Fatalf("accepted=%s err=%v", response.Body.String(), err)
|
||||
}
|
||||
stored, err := repository.GetInstance(ctx, accepted["instance_id"])
|
||||
if err != nil || len(stored.Preview.Ports) == 0 || stored.Preview.Ports[0].HostPort != 45230 {
|
||||
t.Fatalf("stored deployment host ports=%#v error=%v", stored.Preview.Ports, err)
|
||||
}
|
||||
// The HTTP response has returned while the worker is deterministically blocked.
|
||||
<-agent.entered
|
||||
progressReq := httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+accepted["operation_id"], nil)
|
||||
|
||||
@@ -50,6 +50,23 @@
|
||||
<textarea name="description" rows="3">
|
||||
</textarea>
|
||||
</label>
|
||||
<h2>
|
||||
{{.Msg "deployment.network"}}
|
||||
</h2>
|
||||
<p class="help">
|
||||
{{.Msg "deployment.network_help"}}
|
||||
</p>
|
||||
{{range .Deployment.Template.Container.Ports}}
|
||||
{{if .Publish}}
|
||||
<label>
|
||||
{{$.Msg "deployment.host_port"}} · {{.ID}}/{{.Protocol}}
|
||||
<small class="help">
|
||||
{{$.Msg "deployment.container_port"}}: {{.ContainerPort}}/{{.Protocol}}
|
||||
</small>
|
||||
<input required min="1" max="65535" name="host_port_{{.ID}}" type="number" value="{{index $.Deployment.HostPorts .ID}}" inputmode="numeric">
|
||||
</label>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<h2>
|
||||
{{.Msg "deployment.parameters"}}
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user