From 93eff73c9cb000d863faea179d46be9a5168d310 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Fri, 30 Oct 2026 14:59:01 +0100 Subject: [PATCH 1/2] Enhance tunnel management for development and production stages - Introduced handling for development (DEV) and production (PROD) stages in tunnel creation and management. - Updated `CreateSubdomain` and `ParseSubdomain` functions to accommodate stage differentiation in tunnel labels. - Modified various application methods to ensure proper tunnel cleanup and management based on the application stage. - Implemented tests to validate the correct behavior of tunnel creation and parsing for both stages. - Ensured that development runs have unique tunnel labels to prevent conflicts with production installations. --- docker/Dockerfile | 7 +- docker/entrypoint.sh | 9 - src/apps/app_manager.go | 197 +++++++++++++-- src/apps/build_app.go | 2 +- src/apps/dev_tunnels_test.go | 278 +++++++++++++++++++++ src/apps/remove_app.go | 3 + src/apps/run_app.go | 13 +- src/apps/state_observer.go | 27 ++ src/apps/stop_app.go | 14 ++ src/apps/sync_port_state_test.go | 38 +-- src/apps/uninstall_app.go | 9 +- src/tunnel/dev_label_test.go | 122 +++++++++ src/tunnel/frp.go | 63 ++++- src/tunnel/tunnel.go | 4 + src/tunnel/tunnel_more_integration_test.go | 4 +- src/tunnel/tunnel_test.go | 2 +- src/tunnel/tunnel_unit_test.go | 28 +-- 17 files changed, 725 insertions(+), 95 deletions(-) create mode 100644 src/apps/dev_tunnels_test.go create mode 100644 src/tunnel/dev_label_test.go diff --git a/docker/Dockerfile b/docker/Dockerfile index e40d432..d156722 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,14 +30,17 @@ ENV DEBIAN_FRONTEND=noninteractive # plain `install` never touches (it only resolves the named packages). This is a # dev-stack image, but keeping the base current is cheap. See # docs/SECURITY-SCANNING.md. +# +# No dbus (nor network-manager, which pulls it in): the agent binds the host's +# /run/dbus into every app when it finds one, and a /run/dbus of this container +# does not exist on the Docker host, so container creation would fail. The +# agent needs D-Bus only for NetworkManager (-nmw, off here) and RAUC. RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \ apt-transport-https \ ca-certificates \ curl \ gnupg \ lsb-release \ - dbus \ - network-manager \ jq \ libc6 \ musl \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c47301e..94e1581 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -74,15 +74,6 @@ else EOF fi -# ------------------------------------------------------------------ -# Start dbus (required by the agent for some system bus operations). -# Non-fatal: not every host grants the permissions we need and the -# agent copes without dbus in most local scenarios. -# ------------------------------------------------------------------ -dbus-uuidgen > /var/lib/dbus/machine-id 2>/dev/null || true -mkdir -p /var/run/dbus -dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address 2>/dev/null || true - AGENT_ENV="${AGENT_ENV:-local}" AGENT_NMW="${AGENT_NMW:-false}" diff --git a/src/apps/app_manager.go b/src/apps/app_manager.go index 235ceb2..2d01a2e 100644 --- a/src/apps/app_manager.go +++ b/src/apps/app_manager.go @@ -52,6 +52,11 @@ type AppManager struct { reservationNotes map[hostPortKey]uint64 reservationNoteLock sync.Mutex + // labelTooLongNotes holds the development-run tunnels whose too-long + // label was already reported in the app log for the current run of the + // container (see noteLabelTooLong). Guarded by tunnelRebuildLock. + labelTooLongNotes map[string]struct{} + // databaseUnsynced is set when this start created the database file. // Until the first sync with the backend fills it, an empty database says // nothing about which containers are orphans: an agent that opened the @@ -189,11 +194,6 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo log.Debug().Str("app", payload.AppName).Msg("syncPortState called") globalConfig := am.StateMachine.Container.GetConfig() - if payload.Stage == common.DEV { - log.Debug().Msg("Skipping syncPortState for DEV stage") - return nil - } - // Degrade cleanly when tunnels are unavailable (frpc missing/quarantined, // or the device can't reach the tunnel server): skip the frpc calls, never // error, so app starts are not blocked by an absent tunnel feature. @@ -213,6 +213,16 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo requestedState := app.RequestedState app.StateLock.Unlock() + // Whether the app's tunnels should stand. A PROD install's come up with + // the request to run (the crashloop restarts a failed app) and stay while + // it runs. A development run is never restarted, so its tunnels follow + // the container: up only while it runs, gone once it stopped or crashed. + // The pass after a successful run transition (VerifyState) brings them up. + appRunning := requestedState == common.RUNNING || curAppState == common.RUNNING + if payload.Stage == common.DEV { + appRunning = curAppState == common.RUNNING + } + portRules, err := tunnel.InterfaceToPortForwardRule(payload.Ports) if err != nil { log.Error().Stack().Err(err).Msg("Failed to convert interface to port forward rule") @@ -239,7 +249,7 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo } for _, portRule := range portRules { - subdomain := tunnel.CreateSubdomain(tunnel.Protocol(portRule.Protocol), uint64(globalConfig.ReswarmConfig.DeviceKey), payload.AppName, portRule.Port) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol(portRule.Protocol), uint64(globalConfig.ReswarmConfig.DeviceKey), payload.AppName, portRule.Port, payload.Stage) tunnelID := tunnel.CreateTunnelID(subdomain, portRule.Protocol) newConfig := tunnel.TunnelConfig{} @@ -263,8 +273,32 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo dialPort, dialPortKnown, reservationFailure = am.resolveTunnelHostPort(payload, portRule) } - if portRule.Active { - if tunnelsAvailable && (requestedState == common.RUNNING || curAppState == common.RUNNING) { + // A development run tunnels its web ports only: what it serves there + // is what a developer inspects in the browser. Its tcp/udp ports are + // published on the device's network (computePortBindings) but take no + // port from the tunnel server's shared pool. + devNoTunnel := payload.Stage == common.DEV && !isWebProtocol(portRule.Protocol) + // A DEV label is four characters longer than the PROD one, and an app + // name of 40 characters can push it past what a DNS label may hold: + // such a tunnel could never be reached, so it is not opened. + devLabelTooLong := payload.Stage == common.DEV && !devNoTunnel && len(subdomain) > tunnel.MaxLabelLength + + if devNoTunnel { + log.Debug().Str("app", payload.AppName).Uint64("port", portRule.Port).Str("protocol", portRule.Protocol). + Msg("No tunnel for a tcp/udp port of a development run") + } else if devLabelTooLong { + if portRule.Active && appRunning && tunnelsAvailable { + log.Warn().Str("app", payload.AppName).Str("subdomain", subdomain). + Msg("No tunnel for a port of a development run: its label exceeds 63 characters") + if am.noteLabelTooLong(tunnelID) { + am.writeAppLog(payload.ContainerName.Dev, fmt.Sprintf("Port %d of %s gets no tunnel: its address %s is longer than the %d characters a part of a domain name may have. A shorter app name makes it reachable.", portRule.Port, payload.AppName, subdomain, tunnel.MaxLabelLength)) + } + } else if !appRunning { + // Said again at the next run. + am.forgetLabelTooLong(tunnelID) + } + } else if portRule.Active { + if tunnelsAvailable && appRunning { if !dialPortKnown { // Fresh app that has not been started under managed ports // yet. The post-transition syncPortState creates the @@ -279,6 +313,7 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo LocalIP: portRule.LocalIP, RemotePort: wantRemote, Reserved: reservedRemote != 0, + Stage: payload.Stage, } tnl := am.tunnelManager.Get(tunnelID) @@ -376,6 +411,27 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo } } // continue + } else if tunnelsAvailable && payload.Stage == common.DEV { + // A development run that is not running keeps no tunnel: it + // would only answer with a gateway error, and nothing restarts + // the container behind it. Nothing to do (no config rewrite, no + // reload) when there is none. + if am.tunnelManager.Get(tunnelID) != nil || am.tunnelInConfigFile(tunnelID) { + log.Info().Str("tunnelID", tunnelID).Str("state", string(curAppState)). + Msg("Removing the tunnel of a development run that is not running") + err := am.tunnelManager.RemoveTunnel(tunnel.TunnelConfig{ + Subdomain: subdomain, + AppName: payload.AppName, + Protocol: tunnel.Protocol(portRule.Protocol), + LocalPort: portRule.Port, + LocalIP: portRule.LocalIP, + RemotePort: portRule.RemotePort, + Stage: payload.Stage, + }) + if err != nil { + log.Error().Stack().Err(err).Msg("Failed to remove the tunnel of a development run") + } + } } } else if tunnelsAvailable { // Remove tunnel when it's not active (regardless of app state). @@ -395,6 +451,7 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo LocalPort: portRule.Port, LocalIP: portRule.LocalIP, RemotePort: portRule.RemotePort, + Stage: payload.Stage, } log.Info().Str("tunnelID", tunnelID).Msg("Removing tunnel as it is not active") @@ -466,8 +523,11 @@ func (am *AppManager) syncPortState(payload common.TransitionPayload, app *commo } // Live-update the bind-mounted env files ({NAME}.txt / {NAME}_CLOUD.txt) - // so running containers see fresh tunnel ports without a restart. - refreshRemotePortEnvFiles(globalConfig, payload.Stage, payload.AppName, newPorts) + // so running containers see fresh tunnel ports without a restart. A + // development run has no remote ports (remotePortEnvVars). + if payload.Stage != common.DEV { + refreshRemotePortEnvFiles(globalConfig, payload.Stage, payload.AppName, newPorts) + } // err = am.AppStore.UpdateLocalRequestedState(payload) // if err != nil { @@ -586,6 +646,39 @@ func (am *AppManager) noteReservationFailure(tunnelID string, failure string) bo return true } +// noteLabelTooLong reports whether the app-log line about a development run's +// tunnel label being too long still has to be written, and records it as +// written. syncPortState runs on every sync pass; the line is said once per +// run of the container (forgetLabelTooLong clears it when the run ends). +func (am *AppManager) noteLabelTooLong(tunnelID string) bool { + am.tunnelRebuildLock.Lock() + defer am.tunnelRebuildLock.Unlock() + if am.labelTooLongNotes == nil { + am.labelTooLongNotes = make(map[string]struct{}) + } + if _, said := am.labelTooLongNotes[tunnelID]; said { + return false + } + am.labelTooLongNotes[tunnelID] = struct{}{} + return true +} + +func (am *AppManager) forgetLabelTooLong(tunnelID string) { + am.tunnelRebuildLock.Lock() + defer am.tunnelRebuildLock.Unlock() + delete(am.labelTooLongNotes, tunnelID) +} + +// isWebProtocol reports whether a port rule serves http or https (an empty +// protocol is http, as InterfaceToPortForwardRule normalizes it). +func isWebProtocol(protocol string) bool { + switch tunnel.Protocol(protocol) { + case tunnel.HTTP, tunnel.HTTPS, "": + return true + } + return false +} + // noteTunnelHealthy clears the backoff for a tunnel that is serving again, so // the next genuine failure starts from the base delay rather than a stale one. func (am *AppManager) noteTunnelHealthy(tunnelID string) { @@ -687,9 +780,12 @@ func (am *AppManager) retryPortSync(appKey uint64, stage common.Stage) { } } -// SyncAllPortStates re-syncs the tunnels of every PROD app from its stored -// requested state, one app after another. The tunnel manager runs it whenever -// the device becomes able to tunnel (see tunnel.FrpTunnelManager.SetOnAvailable): +// SyncAllPortStates re-syncs the tunnels of every app — PROD installs and +// development runs — from its stored requested state, one app after another. +// frpc.yaml starts empty on every agent start, so this is also what brings a +// still-running development run's tunnels back after an agent restart. The +// tunnel manager runs it whenever the device becomes able to tunnel (see +// tunnel.FrpTunnelManager.SetOnAvailable): // tunnels skipped while it could not, and adds that failed while frpc was // starting, are reconciled the moment it can tunnel again. Nothing else would // ask for them. diskguard's OnRecover runs it when a disk emergency clears, @@ -709,10 +805,6 @@ func (am *AppManager) SyncAllPortStates() { log.Info().Msg("Reconciling the tunnels of every app") for _, payload := range payloads { - // syncPortState never tunnels DEV apps. - if payload.Stage != common.PROD { - continue - } am.retryPortSync(payload.AppKey, payload.Stage) } } @@ -734,14 +826,17 @@ func (am *AppManager) tunnelInConfigFile(tunnelID string) bool { return false } -// RemoveAppTunnels tears down every tunnel belonging to an app — memory, -// config file and frpc alike. Called on uninstall: without it the app's -// proxies survived removal, kept dialing freed host ports, and their stale -// bookkeeping made the next install of the same app skip recreating them. +// RemoveAppTunnels tears down every tunnel belonging to one install of an app +// (its PROD install, or its development run) — memory, config file and frpc +// alike. Called on uninstall: without it the app's proxies survived removal, +// kept dialing freed host ports, and their stale bookkeeping made the next +// install of the same app skip recreating them. Called for a development run +// whenever its container stops, crashes or is removed: its tunnels live only +// while it runs. The other stage's tunnels of the same app are left alone. // File-driven (GetTunnelConfig) so proxies left over from a previous agent // run are removed too. -func (am *AppManager) RemoveAppTunnels(appName string) { - if !am.tunnelManager.TunnelCapable() { +func (am *AppManager) RemoveAppTunnels(appName string, stage common.Stage) { + if am == nil || am.tunnelManager == nil || !am.tunnelManager.TunnelCapable() { return } configs, err := am.tunnelManager.GetTunnelConfig() @@ -753,8 +848,15 @@ func (am *AppManager) RemoveAppTunnels(appName string) { if !strings.EqualFold(cfg.AppName, appName) { continue } + cfgStage := cfg.Stage + if cfgStage == "" { + cfgStage = common.PROD + } + if cfgStage != stage { + continue + } if err := am.tunnelManager.RemoveTunnel(cfg); err != nil { - log.Error().Stack().Err(err).Str("app", appName).Str("subdomain", cfg.Subdomain).Msg("Failed to remove app tunnel on uninstall") + log.Error().Stack().Err(err).Str("app", appName).Str("stage", string(stage)).Str("subdomain", cfg.Subdomain).Msg("Failed to remove app tunnel") } } } @@ -837,8 +939,9 @@ func (am *AppManager) RequestAppState(payload common.TransitionPayload) error { } } - // Its own call, not folded into syncPortState: that one early-returns for - // DEV, and DEV apps need their credential refreshed just as much. + // Its own call, not folded into syncPortState: that one returns early + // when an app has no port rules, and every app needs its credential + // refreshed. refreshAppCredentialEnvFiles(am.StateMachine.Container.GetConfig(), am.StateMachine.AppCredKey(), payload) err = am.syncPortState(payload, app) @@ -1530,6 +1633,10 @@ func (am *AppManager) EnsureRemoteRequestedStates() error { app.StateLock.Unlock() if !devStopOutstanding(payload.RequestedState, currentState) { log.Debug().Str("app", payload.AppName).Msg("Skipping reconnect reconcile for DEV") + // Its tunnels still follow the container and the port rules + // the cloud holds (UpdateLocalRequestedAppStatesWithRemote + // adopted them): a sync pass, no transition. + safe.Go(func() { am.retryPortSync(payload.AppKey, payload.Stage) }) continue } } else if payload.RequestedState == common.PUBLISHED || payload.RequestedState == common.BUILT { @@ -1584,12 +1691,50 @@ func (am *AppManager) UpdateLocalRequestedAppStatesWithRemote(newestPayloads []c if err != nil { return err } + continue + } + + // Not fatal: a development run's port rules must not hold up the + // reconcile of the apps after it in the list. + if err := am.adoptRemoteDevPorts(payload); err != nil { + log.Error().Err(err).Str("app", payload.AppName).Msg("Failed to store the port rules of a development run") } } return nil } +// adoptRemoteDevPorts takes a development run's port rules from the cloud's +// requested state into the stored one, and nothing else of it. A developer's +// Active switch made while the device was offline never arrives otherwise: +// the cloud pushes only to a connected device, and the bulk sync leaves DEV +// apps to the developer's interactive requests. The reconnect pass +// (EnsureRemoteRequestedStates) then re-syncs the run's tunnels from it. +func (am *AppManager) adoptRemoteDevPorts(remote common.TransitionPayload) error { + local, err := am.AppStore.GetRequestedState(remote.AppKey, remote.Stage) + if err != nil { + // Nothing stored: the next interactive request brings the rules. + return nil + } + if samePortRules(local.Ports, remote.Ports) { + return nil + } + log.Info().Str("app", remote.AppName).Msg("Taking the port rules of a development run from the cloud") + local.Ports = remote.Ports + return am.AppStore.UpdateLocalRequestedState(local) +} + +// samePortRules compares two payloads' port rules by their meaning: the +// stored and the fetched ones decode from JSON through different paths. +func samePortRules(a, b []interface{}) bool { + ra, errA := tunnel.InterfaceToPortForwardRule(a) + rb, errB := tunnel.InterfaceToPortForwardRule(b) + if errA != nil || errB != nil { + return reflect.DeepEqual(a, b) + } + return reflect.DeepEqual(ra, rb) +} + // devStopOutstanding reports whether a DEV app still runs although the // requested state is a stop or teardown. The cloud records a DEV stop before // pushing it, and the push is dropped while the device is offline; since DEV diff --git a/src/apps/build_app.go b/src/apps/build_app.go index 4cda939..ad8067a 100644 --- a/src/apps/build_app.go +++ b/src/apps/build_app.go @@ -98,7 +98,7 @@ func (sm *StateMachine) generateDotEnvContents(config *config.Config, payload co return "", nil, err } - remotePortEnvs := sm.remotePortEnvVars(config, app.AppName, portRules) + remotePortEnvs := sm.remotePortEnvVars(config, app.AppName, app.Stage, portRules) envLines = append(environmentVariables, append(remotePortEnvs, missingDefaultEnvs...)...) } diff --git a/src/apps/dev_tunnels_test.go b/src/apps/dev_tunnels_test.go new file mode 100644 index 0000000..ce9924c --- /dev/null +++ b/src/apps/dev_tunnels_test.go @@ -0,0 +1,278 @@ +package apps + +import ( + "strings" + "testing" + + "reagent/common" + "reagent/tunnel" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Development runs get a private tunnel for each http(s) port of their port +// template while the container runs. The label carries a -dev marker, so the +// PROD install of the same app on the same device keeps its own tunnel. + +func devTunnelID(t *testing.T, deviceKey int, app string, port uint64, protocol string) (string, string) { + t.Helper() + sub := tunnel.CreateSubdomain(tunnel.Protocol(protocol), uint64(deviceKey), app, port, common.DEV) + return sub, tunnel.CreateTunnelID(sub, protocol) +} + +// readBackConfig is a tunnel as GetTunnelConfig reads it back from frpc.yaml. +func readBackConfig(t *testing.T, deviceKey int, app string, port uint64, protocol string, stage common.Stage) tunnel.TunnelConfig { + t.Helper() + sub := tunnel.CreateSubdomain(tunnel.Protocol(protocol), uint64(deviceKey), app, port, stage) + name := tunnel.CreateTunnelID(sub, protocol) + gotApp, declared, gotStage, ok := tunnel.ParseSubdomain(sub) + require.True(t, ok) + return tunnel.TunnelConfig{Name: name, Subdomain: sub, AppName: gotApp, DeclaredPort: declared, Stage: gotStage, Protocol: tunnel.Protocol(protocol), LocalPort: 41000 + port} +} + +func TestSyncPortStateDevTunnelsWebPortsOnly(t *testing.T) { + am, _, mockTunnel, appStore, _, cfg := amHarness(t) + mockTunnel.EXPECT().TunnelCapable().Return(true).Maybe() + + app := amSeed(t, appStore, 60, "devweb", common.RUNNING, common.DEV) + app.RequestedState = common.RUNNING + + payload := amPayload(60, "devweb", common.RUNNING, common.DEV) + payload.Ports, _ = tunnel.PortForwardRuleToInterface([]common.PortForwardRule{ + {RuleName: "ui", Port: 8080, Protocol: "http", Active: true}, + {RuleName: "mqtt", Port: 1883, Protocol: "tcp", Active: true}, + }) + + // The launch path published both on host ports. + _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 60, Protocol: "tcp", Port: 8080}, 41800) + require.NoError(t, err) + _, err = am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 60, Protocol: "tcp", Port: 1883}, 41801) + require.NoError(t, err) + + sub, id := devTunnelID(t, cfg.ReswarmConfig.DeviceKey, "devweb", 8080, "http") + assert.Equal(t, "1-devweb-8080-dev", sub) + + mockTunnel.EXPECT().Get(id).Return(nil).Once() + // Exactly one add: the http port. The tcp port gets no tunnel. + mockTunnel.EXPECT().AddTunnel(mock.Anything).RunAndReturn(func(conf tunnel.TunnelConfig) (tunnel.TunnelConfig, error) { + assert.Equal(t, sub, conf.Subdomain) + assert.Equal(t, tunnel.HTTP, conf.Protocol) + assert.Equal(t, common.DEV, conf.Stage) + assert.Equal(t, uint64(41800), conf.LocalPort, "frpc dials the DEV container's host port") + return conf, nil + }).Once() + + var saved []interface{} + mockTunnel.EXPECT().SaveRemotePorts(mock.Anything).RunAndReturn(func(p common.TransitionPayload) error { + assert.Equal(t, common.DEV, p.Stage) + saved = p.Ports + return nil + }).Once() + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Once() + + require.NoError(t, am.syncPortState(payload, app)) + + rules := spsRules(t, saved) + require.Len(t, rules, 2, "the tcp rule stays in the written-back list") + assert.Equal(t, uint64(41800), rules[0].HostPort, "the DEV row learns its host ports") + assert.Equal(t, uint64(41801), rules[1].HostPort) + assert.Zero(t, rules[1].RemotePort, "a DEV tcp port takes no tunnel port") +} + +// A development run is not restarted: once it stopped or crashed, its tunnel +// goes, and the request to run it does not bring the tunnel back. +func TestSyncPortStateDevNotRunningRemovesTunnel(t *testing.T) { + for _, current := range []common.AppState{common.FAILED, common.PRESENT} { + t.Run(string(current), func(t *testing.T) { + am, _, mockTunnel, appStore, _, cfg := amHarness(t) + mockTunnel.EXPECT().TunnelCapable().Return(true).Maybe() + + app := amSeed(t, appStore, 61, "devcrash", current, common.DEV) + app.RequestedState = common.RUNNING + + payload := amPayload(61, "devcrash", common.RUNNING, common.DEV) + payload.Ports = spsPorts(t, common.PortForwardRule{RuleName: "ui", Port: 8080, Protocol: "http", Active: true, HostPort: 41810}) + _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 61, Protocol: "tcp", Port: 8080}, 41810) + require.NoError(t, err) + + sub, id := devTunnelID(t, cfg.ReswarmConfig.DeviceKey, "devcrash", 8080, "http") + mockTunnel.EXPECT().Get(id).Return(&tunnel.Tunnel{Config: tunnel.TunnelConfig{Subdomain: sub}}).Once() + mockTunnel.EXPECT().RemoveTunnel(mock.MatchedBy(func(c tunnel.TunnelConfig) bool { + return c.Subdomain == sub && c.Protocol == tunnel.HTTP && c.Stage == common.DEV + })).Return(nil).Once() + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Once() + // AddTunnel is not expected: the strict mock fails the test on it. + + require.NoError(t, am.syncPortState(payload, app)) + }) + } +} + +// Stopping or removing a development run removes its tunnels and leaves the +// PROD install's of the same app; uninstalling the PROD install leaves the +// development run's. +func TestRemoveAppTunnelsIsPerStage(t *testing.T) { + am, _, mockTunnel, _, _, cfg := amHarness(t) + dk := cfg.ReswarmConfig.DeviceKey + + prod := readBackConfig(t, dk, "shared", 8080, "http", common.PROD) + dev := readBackConfig(t, dk, "shared", 8080, "http", common.DEV) + devSecure := readBackConfig(t, dk, "shared", 8443, "https", common.DEV) + otherDev := readBackConfig(t, dk, "other", 8080, "http", common.DEV) + all := []tunnel.TunnelConfig{prod, dev, devSecure, otherDev} + + mockTunnel.EXPECT().TunnelCapable().Return(true).Times(3) + mockTunnel.EXPECT().GetTunnelConfig().Return(all, nil).Times(3) + + // A DEV stop (the StateMachine hook every DEV stop and removal runs). + mockTunnel.EXPECT().RemoveTunnel(dev).Return(nil).Once() + mockTunnel.EXPECT().RemoveTunnel(devSecure).Return(nil).Once() + am.StateMachine.dropDevTunnels(amPayload(70, "shared", common.PRESENT, common.DEV)) + + // A PROD payload never touches tunnels through that hook. + am.StateMachine.dropDevTunnels(amPayload(70, "shared", common.PRESENT, common.PROD)) + + // A PROD uninstall. + mockTunnel.EXPECT().RemoveTunnel(prod).Return(nil).Once() + am.RemoveAppTunnels("shared", common.PROD) + + // The state observer seeing a development run stop on its own. + mockTunnel.EXPECT().RemoveTunnel(otherDev).Return(nil).Once() + am.StateObserver.followDevRun(71, "other", common.FAILED) +} + +// A DEV label is four characters longer than the PROD one. One that exceeds a +// DNS label could never be reached: it gets no tunnel, and the developer reads +// why in the app log, once per run of the container. +func TestSyncPortStateDevLabelTooLong(t *testing.T) { + am, _, mockTunnel, appStore, msg, cfg := amHarness(t) + mockTunnel.EXPECT().TunnelCapable().Return(true).Maybe() + + name := strings.Repeat("a", 53) + app := amSeed(t, appStore, 62, name, common.RUNNING, common.DEV) + app.RequestedState = common.RUNNING + + payload := amPayload(62, name, common.RUNNING, common.DEV) + payload.Ports = spsPorts(t, common.PortForwardRule{RuleName: "ui", Port: 8080, Protocol: "http", Active: true, HostPort: 41820}) + _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 62, Protocol: "tcp", Port: 8080}, 41820) + require.NoError(t, err) + + sub, _ := devTunnelID(t, cfg.ReswarmConfig.DeviceKey, name, 8080, "http") + require.Equal(t, 64, len(sub), "one octet over the limit") + prodSub := tunnel.CreateSubdomain(tunnel.HTTP, uint64(cfg.ReswarmConfig.DeviceKey), name, 8080, common.PROD) + require.Equal(t, 60, len(prodSub)) + + // No Get, no AddTunnel (strict mock); nothing changed, so nothing saved. + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Times(3) + + lines := func() int { + n := 0 + for _, call := range msg.GetPublishCalls() { + if len(call.Args) == 0 { + continue + } + if dict, ok := call.Args[0].(common.Dict); ok { + if chunk, ok := dict["chunk"].(string); ok && strings.Contains(chunk, "gets no tunnel") { + n++ + } + } + } + return n + } + + require.NoError(t, am.syncPortState(payload, app)) + require.NoError(t, am.syncPortState(payload, app)) + assert.Equal(t, 1, lines(), "said once per run, not once per sync pass") + + // The run ends; the next run says it again. + app.CurrentState = common.PRESENT + app.RequestedState = common.PRESENT + require.NoError(t, am.syncPortState(payload, app)) + app.CurrentState = common.RUNNING + app.RequestedState = common.RUNNING + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Once() + require.NoError(t, am.syncPortState(payload, app)) + assert.Equal(t, 2, lines()) +} + +// A developer's Active switch made while the device was offline never reaches +// it as a push. The reconnect bulk sync adopts the DEV row's port rules (and +// nothing else of it), and the run's next sync pass applies them. +func TestReconnectAdoptsDevPortRules(t *testing.T) { + am, _, mockTunnel, appStore, _, cfg := amHarness(t) + mockTunnel.EXPECT().TunnelCapable().Return(true).Maybe() + + amSeed(t, appStore, 63, "devoff", common.RUNNING, common.DEV) + _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 63, Protocol: "tcp", Port: 8080}, 41830) + require.NoError(t, err) + + stored := amPayload(63, "devoff", common.RUNNING, common.DEV) + stored.Ports = spsPorts(t, common.PortForwardRule{RuleName: "ui", Port: 8080, Protocol: "http", Active: true, HostPort: 41830}) + require.NoError(t, appStore.UpdateLocalRequestedState(stored)) + + // The cloud: the developer switched the port off; the requested state is + // not a stop, so it is not applied. + remote := amPayload(63, "devoff", common.BUILT, common.DEV) + remote.Ports = spsPorts(t, common.PortForwardRule{RuleName: "ui", Port: 8080, Protocol: "http", Active: false, HostPort: 41830}) + require.NoError(t, am.UpdateLocalRequestedAppStatesWithRemote([]common.TransitionPayload{remote})) + + got, err := appStore.GetRequestedState(63, common.DEV) + require.NoError(t, err) + assert.Equal(t, common.RUNNING, got.RequestedState, "the run request stays the developer's") + rules := spsRules(t, got.Ports) + require.Len(t, rules, 1) + assert.False(t, rules[0].Active, "the switch arrived") + + // The reconnect pass's sync removes the tunnel. + sub, id := devTunnelID(t, cfg.ReswarmConfig.DeviceKey, "devoff", 8080, "http") + mockTunnel.EXPECT().Get(id).Return(&tunnel.Tunnel{Config: tunnel.TunnelConfig{Subdomain: sub}}).Once() + mockTunnel.EXPECT().RemoveTunnel(mock.MatchedBy(func(c tunnel.TunnelConfig) bool { return c.Subdomain == sub })).Return(nil).Once() + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Once() + am.retryPortSync(63, common.DEV) + + // Same rules again: nothing to adopt, nothing written. + require.NoError(t, am.UpdateLocalRequestedAppStatesWithRemote([]common.TransitionPayload{remote})) +} + +// frpc.yaml starts empty on every agent start: the all-apps reconcile brings a +// still-running development run's tunnel back. +func TestSyncAllPortStatesRebuildsRunningDevTunnel(t *testing.T) { + am, _, mockTunnel, appStore, _, cfg := amHarness(t) + mockTunnel.EXPECT().TunnelCapable().Return(true).Maybe() + + amSeed(t, appStore, 64, "devboot", common.RUNNING, common.DEV) + _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.DEV, AppKey: 64, Protocol: "tcp", Port: 8080}, 41840) + require.NoError(t, err) + + stored := amPayload(64, "devboot", common.RUNNING, common.DEV) + stored.Ports = spsPorts(t, common.PortForwardRule{RuleName: "ui", Port: 8080, Protocol: "http", Active: true, HostPort: 41840}) + require.NoError(t, appStore.UpdateLocalRequestedState(stored)) + + sub, id := devTunnelID(t, cfg.ReswarmConfig.DeviceKey, "devboot", 8080, "http") + mockTunnel.EXPECT().Get(id).Return(nil).Once() + mockTunnel.EXPECT().AddTunnel(mock.Anything).RunAndReturn(func(conf tunnel.TunnelConfig) (tunnel.TunnelConfig, error) { + assert.Equal(t, sub, conf.Subdomain) + assert.Equal(t, uint64(41840), conf.LocalPort) + return conf, nil + }).Once() + mockTunnel.EXPECT().GetState().Return([]tunnel.TunnelState{}, nil).Once() + + am.SyncAllPortStates() +} + +// A development run announces no remote ports: the PROD install's tunnel of +// the same port is not its own. +func TestRemotePortEnvVarsNoneForDev(t *testing.T) { + am, _, mockTunnel, _, _, cfg := amHarness(t) + rules := []common.PortForwardRule{{Port: 1883, Protocol: "tcp", Active: true, CloudRemotePort: 30999}} + + // No Get for DEV (strict mock). + assert.Nil(t, am.StateMachine.remotePortEnvVars(cfg, "envapp", common.DEV, rules)) + + prodSub := tunnel.CreateSubdomain(tunnel.TCP, uint64(cfg.ReswarmConfig.DeviceKey), "envapp", 1883, common.PROD) + mockTunnel.EXPECT().Get(tunnel.CreateTunnelID(prodSub, "tcp")). + Return(&tunnel.Tunnel{Config: tunnel.TunnelConfig{RemotePort: 30111}}).Once() + assert.Contains(t, am.StateMachine.remotePortEnvVars(cfg, "envapp", common.PROD, rules), "REMOTE_PORT_FOR_1883=30111") +} diff --git a/src/apps/remove_app.go b/src/apps/remove_app.go index 565466d..89fdadf 100644 --- a/src/apps/remove_app.go +++ b/src/apps/remove_app.go @@ -27,6 +27,8 @@ func (sm *StateMachine) removeApp(payload common.TransitionPayload, app *common. } func (sm *StateMachine) removeComposeApp(payload common.TransitionPayload, app *common.App) error { + defer sm.dropDevTunnels(payload) + containerName := payload.ContainerName.Dev if payload.Stage == common.PROD { containerName = payload.ContainerName.Prod @@ -119,6 +121,7 @@ func (sm *StateMachine) removeComposeApp(payload common.TransitionPayload, app * } func (sm *StateMachine) removeDevApp(payload common.TransitionPayload, app *common.App) error { + defer sm.dropDevTunnels(payload) err := sm.LogManager.ClearLogHistory(payload.ContainerName.Dev) if err != nil { diff --git a/src/apps/run_app.go b/src/apps/run_app.go index 35c9f70..884c09e 100644 --- a/src/apps/run_app.go +++ b/src/apps/run_app.go @@ -880,7 +880,14 @@ func buildDefaultEnvironmentVariables(config *config.Config, payload common.Tran // internet-facing cloud port an instance backend patched into the sync // payload); a rule's custom remote_port_environment name is emitted alongside // for backward compatibility. -func (sm *StateMachine) remotePortEnvVars(config *config.Config, appName string, portRules []common.PortForwardRule) []string { +// +// A development run gets none: its only tunnels are http(s) ones, which have +// no remote port, and the PROD install's tunnel of the same port is not its +// own. +func (sm *StateMachine) remotePortEnvVars(config *config.Config, appName string, stage common.Stage, portRules []common.PortForwardRule) []string { + if stage == common.DEV { + return nil + } var envs []string for _, portRule := range portRules { names := []string{fmt.Sprintf("REMOTE_PORT_FOR_%d", portRule.Port)} @@ -888,7 +895,7 @@ func (sm *StateMachine) remotePortEnvVars(config *config.Config, appName string, names = append(names, portRule.RemotePortEnvironment) } - subdomain := tunnel.CreateSubdomain(tunnel.Protocol(portRule.Protocol), uint64(config.ReswarmConfig.DeviceKey), appName, portRule.Port) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol(portRule.Protocol), uint64(config.ReswarmConfig.DeviceKey), appName, portRule.Port, stage) tunnelID := tunnel.CreateTunnelID(subdomain, portRule.Protocol) activeTunnel := sm.StateObserver.AppManager.tunnelManager.Get(tunnelID) @@ -997,7 +1004,7 @@ func (sm *StateMachine) computeContainerConfigs(payload common.TransitionPayload } } - remotePortEnvs := sm.remotePortEnvVars(config, app.AppName, portRules) + remotePortEnvs := sm.remotePortEnvVars(config, app.AppName, app.Stage, portRules) // Write all environment variables to files allEnvVars := append(environmentVariables, append(remotePortEnvs, missingDefaultEnvs...)...) diff --git a/src/apps/state_observer.go b/src/apps/state_observer.go index b1ec038..6a49cdb 100644 --- a/src/apps/state_observer.go +++ b/src/apps/state_observer.go @@ -723,6 +723,10 @@ func (so *StateObserver) observeAppState(observerCtx context.Context, stage comm } } + if stage == common.DEV { + so.followDevRun(appKey, appName, latestAppState) + } + // try to transition to the state it's supposed to be at if stage == common.PROD && so.driveCorrectedProdApp(app, latestAppState, containerName) { return @@ -741,6 +745,25 @@ func (so *StateObserver) observeAppState(observerCtx context.Context, stage comm return errorC } +// followDevRun keeps a development run's tunnels in step with what its +// containers show, right after an observer corrected the app's state: a run +// that stopped or crashed loses them (nothing restarts it, and a tunnel in +// front of nothing answers with a gateway error); one seen running again — a +// Compose service's own restart policy, a stop that failed half-way — gets +// them back from its stored requested state. The observer is the only party +// that sees either happen: no transition runs. +func (so *StateObserver) followDevRun(appKey uint64, appName string, state common.AppState) { + am := so.AppManager + if am == nil { + return + } + if state == common.RUNNING { + am.retryPortSync(appKey, common.DEV) + return + } + am.RemoveAppTunnels(appName, common.DEV) +} + // driveCorrectedProdApp is what a PROD observer does right after it has // corrected an app's state to what its containers show: decide who drives the // app back toward its requested state. Returns true when the observer @@ -922,6 +945,10 @@ func (so *StateObserver) observeComposeAppState(observerCtx context.Context, sta return } + if stage == common.DEV { + so.followDevRun(appKey, appName, latestAppState) + } + // try to transition to the state it's supposed to be at containerTopic := common.BuildContainerName(stage, appKey, appName) if stage == common.PROD && so.driveCorrectedProdApp(app, latestAppState, containerTopic) { diff --git a/src/apps/stop_app.go b/src/apps/stop_app.go index 42a3482..73cf2c7 100644 --- a/src/apps/stop_app.go +++ b/src/apps/stop_app.go @@ -146,7 +146,21 @@ func (sm *StateMachine) stopProdComposeApp(payload common.TransitionPayload, app return sm.LogManager.Write(payload.ContainerName.Prod, fmt.Sprintf("Successfully stopped %s", payload.AppName)) } +// dropDevTunnels removes a development run's tunnels: they live only while +// its container runs (see syncPortState), and a stop or a removal ends that. +// Unconditional, also when the stop or removal fails half-way: a container +// that is in fact still running is seen by its state observer, which brings +// the tunnels back (StateObserver.followDevRun). +func (sm *StateMachine) dropDevTunnels(payload common.TransitionPayload) { + if payload.Stage != common.DEV || sm.StateObserver == nil || sm.StateObserver.AppManager == nil { + return + } + sm.StateObserver.AppManager.RemoveAppTunnels(payload.AppName, common.DEV) +} + func (sm *StateMachine) stopDevApp(payload common.TransitionPayload, app *common.App) error { + defer sm.dropDevTunnels(payload) + if payload.DockerCompose != nil { return sm.stopDevComposeApp(payload, app) } diff --git a/src/apps/sync_port_state_test.go b/src/apps/sync_port_state_test.go index 02747e6..231e1ef 100644 --- a/src/apps/sync_port_state_test.go +++ b/src/apps/sync_port_state_test.go @@ -50,7 +50,7 @@ func TestSyncPortStateUsesManagedHostPort(t *testing.T) { require.NoError(t, err) require.Equal(t, uint64(41234), hostPort) - expectedSubdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "portapp", 8080) + expectedSubdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "portapp", 8080, common.PROD) tunnelID := tunnel.CreateTunnelID(expectedSubdomain, "http") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Once() @@ -151,7 +151,7 @@ func TestSyncPortStateReplacesStaleTunnel(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 10, Protocol: "tcp", Port: 8080}, 41300) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "staleapp", 8080) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "staleapp", 8080, common.PROD) staleConfig := tunnel.TunnelConfig{Subdomain: subdomain, Protocol: tunnel.Protocol("http"), LocalPort: 40000} mockTunnel.EXPECT().Get(mock.Anything).Return(&tunnel.Tunnel{Config: staleConfig}).Once() @@ -185,7 +185,7 @@ func TestSyncPortStateKeepsReservedRemotePort(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 12, Protocol: "tcp", Port: 1883}, 40001) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "mqttapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "mqttapp", 1883, common.PROD) existing := tunnel.TunnelConfig{ Subdomain: subdomain, Protocol: tunnel.Protocol("tcp"), @@ -316,7 +316,7 @@ func TestSyncPortStateRebuildsDeadTunnel(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 21, Protocol: "tcp", Port: 9090}, 41500) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "deadtunnel", 9090) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "deadtunnel", 9090, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "http") stale := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "deadtunnel", Protocol: tunnel.Protocol("http"), LocalPort: 41500} @@ -352,7 +352,7 @@ func TestUninstallRemovesAppTunnels(t *testing.T) { // Only the uninstalled app's tunnel is removed; the bystander stays. mockTunnel.EXPECT().RemoveTunnel(mine).Return(nil).Once() - am.RemoveAppTunnels("victim") + am.RemoveAppTunnels("victim", common.PROD) } // A tunnel frps keeps refusing must be retried, but PACED. The refusal is @@ -375,7 +375,7 @@ func TestSyncPortStatePacesRepeatedDeadTunnelRebuilds(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 22, Protocol: "tcp", Port: 9091}, 41501) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "flappy", 9091) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "flappy", 9091, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "http") stale := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "flappy", Protocol: tunnel.Protocol("http"), LocalPort: 41501, RemotePort: 30222} @@ -452,7 +452,7 @@ func TestSyncPortStateReservedRemoteDrivesConfig(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 15000}, nil).Once() - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "reservedapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "reservedapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Once() @@ -501,7 +501,7 @@ func TestSyncPortStateMovesLiveTunnelToReservedRemote(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 15000}, nil).Once() - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "moveapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "moveapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") existing := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "moveapp", Protocol: tunnel.Protocol("tcp"), LocalPort: 15000, RemotePort: 30001} @@ -547,7 +547,7 @@ func TestSyncPortStateWriteBackGuardKeepsReservedRemote(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 15000}, nil).Once() - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "guardapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "guardapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Once() @@ -590,7 +590,7 @@ func TestSyncPortStateReservedAddFailureSetsReservationError(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 15000}, nil).Times(2) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "failapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "failapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Times(2) @@ -661,7 +661,7 @@ func TestSyncPortStateReservedNoopRoundTrip(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 15000}, nil).Once() - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "noopapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "noopapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") existing := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "noopapp", Protocol: tunnel.Protocol("tcp"), LocalPort: 15000, RemotePort: 30500} @@ -695,7 +695,7 @@ func TestSyncPortStateHttpRuleIgnoresReservations(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 45, Protocol: "tcp", Port: 8080}, 41234) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "webapp", 8080) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("http"), uint64(cfg.ReswarmConfig.DeviceKey), "webapp", 8080, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "http") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Once() @@ -739,7 +739,7 @@ func TestSyncPortStateReservationOnRunningAppKeepsLiveDialPort(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, payload.ContainerName.Prod). Return(map[string]uint64{"1883/tcp": 41234}, nil).Times(3) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "pendingapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "pendingapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") existing := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "pendingapp", Protocol: tunnel.Protocol("tcp"), LocalPort: 41234, RemotePort: 30001} @@ -860,7 +860,7 @@ func TestSyncPortStateRetriesFailedAddWithoutTrigger(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 50, Protocol: "tcp", Port: 1883}, 41600) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "relayapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "relayapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") up := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "relayapp", Protocol: tunnel.Protocol("tcp"), LocalPort: 41600, RemotePort: 30778} @@ -935,7 +935,7 @@ func TestSyncPortStateRetriesDeferredRebuild(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 51, Protocol: "udp", Port: 51820}, 41700) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("udp"), uint64(cfg.ReswarmConfig.DeviceKey), "refusedapp", 51820) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("udp"), uint64(cfg.ReswarmConfig.DeviceKey), "refusedapp", 51820, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "udp") stale := tunnel.TunnelConfig{Subdomain: subdomain, AppName: "refusedapp", Protocol: tunnel.Protocol("udp"), LocalPort: 41700, RemotePort: 30801} @@ -993,7 +993,7 @@ func TestSyncPortStateFirstAddSchedulesNoRetry(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 52, Protocol: "tcp", Port: 1883}, 41800) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "freshtunnel", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "freshtunnel", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Once() @@ -1025,7 +1025,7 @@ func TestPortSyncRetryStopsForRemovedApp(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 53, Protocol: "tcp", Port: 1883}, 41900) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "goneapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "goneapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") // Exactly the first pass's calls: the strict mock fails the test on any @@ -1073,7 +1073,7 @@ func TestPortSyncRetryStandsDownInDiskEmergency(t *testing.T) { _, err := am.hostPorts.RecoverOrReserve(hostPortKey{Stage: common.PROD, AppKey: 55, Protocol: "tcp", Port: 1883}, 42000) require.NoError(t, err) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "fullapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "fullapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") // The first pass's add fails and schedules a retry. @@ -1138,7 +1138,7 @@ func TestSyncPortStateRetryLogsReservationFailureOnce(t *testing.T) { mockContainer.EXPECT().GetContainerPortBindings(mock.Anything, mock.Anything). Return(map[string]uint64{"1883/tcp": 15000}, nil).Times(3) - subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "pinnedapp", 1883) + subdomain := tunnel.CreateSubdomain(tunnel.Protocol("tcp"), uint64(cfg.ReswarmConfig.DeviceKey), "pinnedapp", 1883, common.PROD) tunnelID := tunnel.CreateTunnelID(subdomain, "tcp") mockTunnel.EXPECT().Get(tunnelID).Return(nil).Times(3) diff --git a/src/apps/uninstall_app.go b/src/apps/uninstall_app.go index ef72f48..b997fee 100644 --- a/src/apps/uninstall_app.go +++ b/src/apps/uninstall_app.go @@ -26,11 +26,10 @@ func (sm *StateMachine) uninstallApp(payload common.TransitionPayload, app *comm // Tunnels first, while their configs still name the app: leaving them // standing kept frpc dialing the just-freed host ports, and their // bookkeeping made a later reinstall skip recreating them (the - // "tunnel already exists" deadlock). PROD only — DEV apps never get - // tunnels (syncPortState skips DEV). - if payload.Stage == common.PROD { - am.RemoveAppTunnels(payload.AppName) - } + // "tunnel already exists" deadlock). Only this stage's: the PROD + // install and the development run of an app have tunnels of their + // own, and uninstalling one leaves the other's. + am.RemoveAppTunnels(payload.AppName, payload.Stage) am.hostPorts.ReleaseApp(payload.Stage, payload.AppKey) } diff --git a/src/tunnel/dev_label_test.go b/src/tunnel/dev_label_test.go new file mode 100644 index 0000000..8d3db08 --- /dev/null +++ b/src/tunnel/dev_label_test.go @@ -0,0 +1,122 @@ +package tunnel + +import ( + "strings" + "testing" + + "reagent/common" + "reagent/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A development run's tunnel label carries a trailing -dev, so it never +// collides with the PROD install of the same app and port. The tunnel server, +// the backend and Studio parse the same grammar; PROD labels must not change. +func TestCreateSubdomainDevLabel(t *testing.T) { + tests := []struct { + protocol Protocol + stage common.Stage + want string + }{ + {HTTP, common.PROD, "12-myapp-8080"}, + {HTTPS, common.PROD, "secure-12-myapp-8080"}, + {TCP, common.PROD, "12-myapp-8080"}, + {HTTP, common.DEV, "12-myapp-8080-dev"}, + {HTTPS, common.DEV, "secure-12-myapp-8080-dev"}, + {UDP, common.DEV, "12-myapp-8080-dev"}, + } + for _, tt := range tests { + t.Run(string(tt.protocol)+"/"+string(tt.stage), func(t *testing.T) { + assert.Equal(t, tt.want, CreateSubdomain(tt.protocol, 12, "MyApp", 8080, tt.stage)) + }) + } +} + +func TestParseSubdomain(t *testing.T) { + tests := []struct { + label string + app string + port uint64 + stage common.Stage + }{ + {"12-foo-80", "foo", 80, common.PROD}, + {"12-foo-80-dev", "foo", 80, common.DEV}, + {"secure-12-foo-80-dev", "foo", 80, common.DEV}, + {"secure-12-foo-443", "foo", 443, common.PROD}, + // The marker sits after the port: an app may well be named x-dev. + {"12-x-dev-80", "x-dev", 80, common.PROD}, + {"12-x-dev-80-dev", "x-dev", 80, common.DEV}, + // Hyphens and digits in the app name. + {"12-my-app-2-8080", "my-app-2", 8080, common.PROD}, + {"12-my-app-2-8080-dev", "my-app-2", 8080, common.DEV}, + {"7-80-81", "80", 81, common.PROD}, + // Proxy names (label + -protocol) parse too. + {"12-foo-80-dev-http", "foo", 80, common.DEV}, + {"12-foo-80-tcp", "foo", 80, common.PROD}, + {"secure-12-foo-80-dev-https", "foo", 80, common.DEV}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + app, port, stage, ok := ParseSubdomain(tt.label) + require.True(t, ok) + assert.Equal(t, tt.app, app) + assert.Equal(t, tt.port, port) + assert.Equal(t, tt.stage, stage) + }) + } + + for _, bad := range []string{"", "foo-bar", "i5-files", "12-foo", "12-foo-dev", "x12-foo-80"} { + _, _, _, ok := ParseSubdomain(bad) + assert.False(t, ok, bad) + } +} + +// Whatever CreateSubdomain builds, ParseSubdomain reads back unchanged, for +// both stages and every protocol, also from the proxy name. +func TestSubdomainRoundTrip(t *testing.T) { + for _, app := range []string{"app", "x-dev", "a1-b2-3", "80", "dev"} { + for _, protocol := range []Protocol{HTTP, HTTPS, TCP, UDP} { + for _, stage := range []common.Stage{common.PROD, common.DEV} { + subdomain := CreateSubdomain(protocol, 4749, app, 1883, stage) + for _, label := range []string{subdomain, CreateTunnelID(subdomain, string(protocol))} { + gotApp, gotPort, gotStage, ok := ParseSubdomain(label) + require.True(t, ok, label) + assert.Equal(t, app, gotApp, label) + assert.Equal(t, uint64(1883), gotPort, label) + assert.Equal(t, stage, gotStage, label) + } + } + } + } +} + +// frpc.yaml does not store the stage; GetTunnelConfig reads it back from the +// proxy name, so a DEV and a PROD tunnel of the same app and port stay apart. +func TestGetTunnelConfigReadsStage(t *testing.T) { + cfg := builderConfig(t, &config.ReswarmConfig{Environment: string(common.PRODUCTION)}) + builder := NewTunnelConfigBuilder(cfg) + + builder.AddTunnelConfig(TunnelConfig{Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.PROD), Protocol: HTTP, LocalPort: 41000}) + builder.AddTunnelConfig(TunnelConfig{Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.DEV), Protocol: HTTP, LocalPort: 41001}) + builder.AddTunnelConfig(TunnelConfig{Subdomain: CreateSubdomain(HTTPS, 9, "web", 8443, common.DEV), Protocol: HTTPS, LocalPort: 41002}) + + configs, err := builder.GetTunnelConfig() + require.NoError(t, err) + require.Len(t, configs, 3) + + got := map[string]common.Stage{} + for _, c := range configs { + assert.Equal(t, "web", c.AppName) + got[c.Name] = c.Stage + if strings.HasSuffix(c.Name, "-dev-http") || strings.HasSuffix(c.Name, "-dev-https") { + assert.Equal(t, common.DEV, c.Stage, c.Name) + } + } + assert.Equal(t, map[string]common.Stage{ + "9-web-8080-http": common.PROD, + "9-web-8080-dev-http": common.DEV, + "secure-9-web-8443-dev-https": common.DEV, + }, got) +} diff --git a/src/tunnel/frp.go b/src/tunnel/frp.go index 0d86ce4..35da2ef 100644 --- a/src/tunnel/frp.go +++ b/src/tunnel/frp.go @@ -48,6 +48,10 @@ type TunnelConfig struct { // falling back to an allocated port. Not serialized to frpc.yaml, so it // does not survive a config read-back. Reserved bool + // Stage is the install the tunnel belongs to: DEV for a development run + // (its label ends in -dev), PROD otherwise. Not written to frpc.yaml; + // GetTunnelConfig parses it back from the proxy name. + Stage common.Stage } // YAML config structures matching frp v0.65.0 format @@ -206,11 +210,22 @@ const TEST_SERVER_ADDR = "app.ironflock.dev" // warning is logged every time the config is built while it is set. const TUNNEL_TLS_INSECURE_SKIP_VERIFY = "TUNNEL_TLS_INSECURE_SKIP_VERIFY" -// Matches "{deviceKey}-{appName}-{declaredPort}" (with an optional "secure-" -// prefix and, in TCP/UDP proxy names, a "-{protocol}" suffix — both ignored -// by the unanchored match). Group 1 is the app name, group 2 the declared -// port. -var subdomainRegex = regexp.MustCompile(`\d+-(.*)-(\d+)`) +// Matches a tunnel label, "[secure-]{deviceKey}-{appName}-{declaredPort}[-dev]", +// tolerating a trailing "-{protocol}" (a proxy name) as well. Group 1 is the +// app name, group 2 the declared port, group 3 the development-run marker. +// Anchored, so the marker cannot be mistaken for part of the app name or the +// other way round: a PROD label always ends in digits, a DEV label in -dev +// ("12-x-dev-80" is PROD app x-dev, "12-x-80-dev" DEV app x). +var subdomainRegex = regexp.MustCompile(`^(?:secure-)?\d+-(.+)-(\d+)(-dev)?(?:-(?:http|https|tcp|udp))?$`) + +// devLabelSuffix marks the tunnel of a development run: the PROD install of +// the same app on the same device keeps its own label, URL and proxy. The +// tunnel server, the backend and Studio parse the same grammar. +const devLabelSuffix = "-dev" + +// MaxLabelLength is the longest DNS label. An http(s) tunnel's subdomain is a +// label of its URL's host; a longer one never resolves. +const MaxLabelLength = 63 func NewTunnelConfigBuilder(cfg *config.Config) TunnelConfigBuilder { return initialize(cfg) @@ -231,14 +246,39 @@ func tunnelAuthProof(secret string, deviceKey int) string { return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } -func CreateSubdomain(protocol Protocol, deviceKey uint64, appName string, localPort uint64) string { +// CreateSubdomain builds a tunnel's label: "{deviceKey}-{appName}-{port}", +// lower case, with "-dev" appended for a development run and "secure-" +// prefixed for https. PROD labels are unchanged by the stage. +func CreateSubdomain(protocol Protocol, deviceKey uint64, appName string, localPort uint64, stage common.Stage) string { baseSubdomain := strings.ToLower(fmt.Sprintf("%d-%s-%d", deviceKey, appName, localPort)) + if stage == common.DEV { + baseSubdomain += devLabelSuffix + } if protocol == HTTPS { return fmt.Sprintf("%s-%s", "secure", baseSubdomain) } return baseSubdomain } +// ParseSubdomain reads the app name, declared port and stage back from a +// tunnel label (or a proxy name, see subdomainRegex). ok is false for a label +// of another shape. +func ParseSubdomain(subdomain string) (appName string, declaredPort uint64, stage common.Stage, ok bool) { + result := subdomainRegex.FindStringSubmatch(subdomain) + if result == nil { + return "", 0, "", false + } + port, err := strconv.ParseUint(result[2], 10, 64) + if err != nil { + return "", 0, "", false + } + stage = common.PROD + if result[3] != "" { + stage = common.DEV + } + return result[1], port, stage, true +} + func initialize(cfg *config.Config) TunnelConfigBuilder { frpcConfigPath := filepath.Join(cfg.CommandLineArguments.AgentDir, "frpc.yaml") @@ -734,13 +774,10 @@ func (builder *TunnelConfigBuilder) GetTunnelConfig() ([]TunnelConfig, error) { DeclaredPort: uint64(proxy.LocalPort), } - result := subdomainRegex.FindStringSubmatch(subdomain) - if len(result) > 2 { - tunnelConfig.AppName = result[1] - declaredPort, err := strconv.ParseUint(result[2], 10, 64) - if err == nil { - tunnelConfig.DeclaredPort = declaredPort - } + if appName, declaredPort, stage, ok := ParseSubdomain(subdomain); ok { + tunnelConfig.AppName = appName + tunnelConfig.DeclaredPort = declaredPort + tunnelConfig.Stage = stage } else { log.Error().Str("subdomain", subdomain).Str("name", proxy.Name).Msg("Failed to parse app name from tunnel subdomain") } diff --git a/src/tunnel/tunnel.go b/src/tunnel/tunnel.go index d6fb450..3590183 100644 --- a/src/tunnel/tunnel.go +++ b/src/tunnel/tunnel.go @@ -366,6 +366,9 @@ type TunnelState struct { Error bool `json:"error"` ErrorMessage string `json:"error_message"` URL string `json:"url"` + // Stage tells a development run's tunnel (DEV) from the PROD install's of + // the same app and port. + Stage common.Stage `json:"stage"` } func parseProxyStatus(text string) ([]TunnelStatus, error) { @@ -1617,6 +1620,7 @@ func (frpTm *FrpTunnelManager) GetState() ([]TunnelState, error) { // number. Port: tunnelConfig.DeclaredPort, AppName: tunnelConfig.AppName, + Stage: tunnelConfig.Stage, Error: tunnelStatus.Error != "", ErrorMessage: tunnelStatus.Error, Active: tunnelStatus.Status == "running", diff --git a/src/tunnel/tunnel_more_integration_test.go b/src/tunnel/tunnel_more_integration_test.go index 2a93d8e..fdca4fe 100644 --- a/src/tunnel/tunnel_more_integration_test.go +++ b/src/tunnel/tunnel_more_integration_test.go @@ -147,7 +147,7 @@ func TestTunnelLifecycleAgainstLiveServer(t *testing.T) { require.NoError(t, tm.Reset(), "reset should clear any pre-existing tunnel config") - subdomain := CreateSubdomain(HTTP, 1, "LifecycleTest", 18080) + subdomain := CreateSubdomain(HTTP, 1, "LifecycleTest", 18080, common.PROD) want := TunnelConfig{Protocol: HTTP, Subdomain: subdomain, LocalPort: 18080, RemotePort: 0} tunnelID := CreateTunnelID(subdomain, string(HTTP)) @@ -213,7 +213,7 @@ func TestStatusUnknownTunnelAgainstLiveServer(t *testing.T) { require.NoError(t, tm.Reset()) - unknownID := CreateTunnelID(CreateSubdomain(HTTP, 999, "no-such-app", 65000), string(HTTP)) + unknownID := CreateTunnelID(CreateSubdomain(HTTP, 999, "no-such-app", 65000, common.PROD), string(HTTP)) _, err := tm.Status(unknownID) assert.ErrorIs(t, err, errdefs.ErrNotFound, "Status of an unknown tunnel should be not-found") diff --git a/src/tunnel/tunnel_test.go b/src/tunnel/tunnel_test.go index 4641a9f..f3ac7c3 100644 --- a/src/tunnel/tunnel_test.go +++ b/src/tunnel/tunnel_test.go @@ -93,7 +93,7 @@ func TestAddTunnel(t *testing.T) { t.Fatalf("Failed to reset tunnel %s", err.Error()) } - subdomain := CreateSubdomain(HTTP, 1, "Test", 8080) + subdomain := CreateSubdomain(HTTP, 1, "Test", 8080, common.PROD) initialConfig := TunnelConfig{Protocol: HTTP, Subdomain: subdomain, LocalPort: 8080, RemotePort: 0} cfg, err := tunnelManager.AddTunnel(initialConfig) if err != nil { diff --git a/src/tunnel/tunnel_unit_test.go b/src/tunnel/tunnel_unit_test.go index 7ef0ede..226807a 100644 --- a/src/tunnel/tunnel_unit_test.go +++ b/src/tunnel/tunnel_unit_test.go @@ -103,7 +103,7 @@ func TestCreateSubdomain(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := CreateSubdomain(tt.protocol, tt.deviceKey, tt.appName, tt.localPort) + got := CreateSubdomain(tt.protocol, tt.deviceKey, tt.appName, tt.localPort, common.PROD) assert.Equal(t, tt.expected, got) }) } @@ -115,7 +115,7 @@ func TestCreateSubdomainParsableByGetTunnelConfig(t *testing.T) { cfg := builderConfig(t, &config.ReswarmConfig{Environment: string(common.PRODUCTION)}) builder := NewTunnelConfigBuilder(cfg) - subdomain := CreateSubdomain(HTTP, 9, "MyApp", 8080) + subdomain := CreateSubdomain(HTTP, 9, "MyApp", 8080, common.PROD) builder.AddTunnelConfig(TunnelConfig{ Subdomain: subdomain, Protocol: HTTP, @@ -703,13 +703,13 @@ func TestConfigBuilderAddAndGetTunnelConfig(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) httpConf := TunnelConfig{ - Subdomain: CreateSubdomain(HTTP, 9, "web", 8080), + Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.PROD), Protocol: HTTP, LocalPort: 8080, LocalIP: "127.0.0.1", } tcpConf := TunnelConfig{ - Subdomain: CreateSubdomain(TCP, 9, "ssh", 22), + Subdomain: CreateSubdomain(TCP, 9, "ssh", 22, common.PROD), Protocol: TCP, LocalPort: 22, RemotePort: 30022, @@ -751,7 +751,7 @@ func TestConfigBuilderAddTunnelConfigDeduplicates(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) conf := TunnelConfig{ - Subdomain: CreateSubdomain(HTTP, 9, "web", 8080), + Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.PROD), Protocol: HTTP, LocalPort: 8080, } @@ -769,7 +769,7 @@ func TestConfigBuilderRemoveTunnelConfig(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) conf := TunnelConfig{ - Subdomain: CreateSubdomain(HTTP, 9, "web", 8080), + Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.PROD), Protocol: HTTP, LocalPort: 8080, } @@ -790,7 +790,7 @@ func TestConfigBuilderSetTunnelVariable(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) conf := TunnelConfig{ - Subdomain: CreateSubdomain(TCP, 9, "ssh", 22), + Subdomain: CreateSubdomain(TCP, 9, "ssh", 22, common.PROD), Protocol: TCP, LocalPort: 22, RemotePort: 30022, @@ -893,7 +893,7 @@ func TestGetTunnelConfigRoundTripsTunnelIdentity(t *testing.T) { {UDP, 51820}, } { builder.AddTunnelConfig(TunnelConfig{ - Subdomain: CreateSubdomain(tc.protocol, 4749, "trumpfqds", tc.port), + Subdomain: CreateSubdomain(tc.protocol, 4749, "trumpfqds", tc.port, common.PROD), Protocol: tc.protocol, LocalPort: 40000 + tc.port, RemotePort: 30000, @@ -904,7 +904,7 @@ func TestGetTunnelConfigRoundTripsTunnelIdentity(t *testing.T) { require.Len(t, configs, 4) for _, got := range configs { - expectedName := CreateTunnelID(CreateSubdomain(got.Protocol, 4749, "trumpfqds", got.DeclaredPort), string(got.Protocol)) + expectedName := CreateTunnelID(CreateSubdomain(got.Protocol, 4749, "trumpfqds", got.DeclaredPort, common.PROD), string(got.Protocol)) // The name frpc reports status under, carried through verbatim. assert.Equal(t, expectedName, got.Name, "%s: proxy name must survive the round-trip", got.Protocol) @@ -923,7 +923,7 @@ func TestGetTunnelConfigRecoversSubdomainFromName(t *testing.T) { cfg := builderConfig(t, &config.ReswarmConfig{Environment: string(common.PRODUCTION)}) builder := NewTunnelConfigBuilder(cfg) - subdomain := CreateSubdomain(HTTP, 4749, "trumpfqds", 55000) + subdomain := CreateSubdomain(HTTP, 4749, "trumpfqds", 55000, common.PROD) builder.AddTunnelConfig(TunnelConfig{Subdomain: subdomain, Protocol: HTTP, LocalPort: 40003}) // Simulate the subdomain key being absent from the stored proxy. @@ -953,12 +953,12 @@ func TestGetTunnelConfigParsesDeclaredPort(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) builder.AddTunnelConfig(TunnelConfig{ - Subdomain: CreateSubdomain(HTTP, 9, "web", 8080), + Subdomain: CreateSubdomain(HTTP, 9, "web", 8080, common.PROD), Protocol: HTTP, LocalPort: 41234, // managed host port, not the declared port }) builder.AddTunnelConfig(TunnelConfig{ - Subdomain: CreateSubdomain(TCP, 9, "ssh", 22), + Subdomain: CreateSubdomain(TCP, 9, "ssh", 22, common.PROD), Protocol: TCP, LocalPort: 41235, RemotePort: 30022, @@ -1045,7 +1045,7 @@ func TestConfigBuilderAddTunnelConfigUpdatesStaleLocalPort(t *testing.T) { cfg := builderConfig(t, &config.ReswarmConfig{Environment: string(common.PRODUCTION)}) builder := NewTunnelConfigBuilder(cfg) - subdomain := CreateSubdomain(TCP, 9, "ssh", 22) + subdomain := CreateSubdomain(TCP, 9, "ssh", 22, common.PROD) builder.AddTunnelConfig(TunnelConfig{ Subdomain: subdomain, Protocol: TCP, @@ -1075,7 +1075,7 @@ func TestConfigBuilderAddTunnelConfigNonZeroRemoteWins(t *testing.T) { builder := NewTunnelConfigBuilder(cfg) conf := TunnelConfig{ - Subdomain: CreateSubdomain(TCP, 9, "ssh", 22), + Subdomain: CreateSubdomain(TCP, 9, "ssh", 22, common.PROD), Protocol: TCP, LocalPort: 22, RemotePort: 30022, From 54687e73367d32af0164ea61339bb5d4868c6e30 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Fri, 30 Oct 2026 14:59:09 +0100 Subject: [PATCH 2/2] release reagent v1.4.9 --- availableVersions.json | 8 ++++---- src/release/version.txt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/availableVersions.json b/availableVersions.json index 29f4cea..f125d0f 100644 --- a/availableVersions.json +++ b/availableVersions.json @@ -1,6 +1,6 @@ { - "production": "1.4.8", - "test": "1.4.8", - "local": "1.4.8", - "all": "1.4.8" + "production": "1.4.9", + "test": "1.4.9", + "local": "1.4.9", + "all": "1.4.9" } diff --git a/src/release/version.txt b/src/release/version.txt index 721b993..5596554 100644 --- a/src/release/version.txt +++ b/src/release/version.txt @@ -1 +1 @@ -1.4.8 \ No newline at end of file +1.4.9 \ No newline at end of file