diff --git a/bootstrap_asset.go b/bootstrap_asset.go index 723f706..b02d550 100644 --- a/bootstrap_asset.go +++ b/bootstrap_asset.go @@ -33,7 +33,7 @@ var bootstrapAssets embed.FS //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml -//go:embed plugins/shared/src +//go:embed plugins/shared/src plugins/shared/build-progress.gradle var gameStackAssets embed.FS // GameStackTar streams the embedded game-stack sources as a tar, rooted so that diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go index 476ce68..3daf521 100644 --- a/bootstrap_asset_test.go +++ b/bootstrap_asset_test.go @@ -147,6 +147,9 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) { // inputs from the script and from each Dockerfile's own COPY lines instead of restating // them here; a fourth image inherits the check for free. func TestGameStackTarCarriesEveryBuildInput(t *testing.T) { + // The plugin builds invoke this shared init script after COPYing the directory; + // a directory entry alone would pass the COPY check even if the script was omitted. + requireEmbedded(t, "plugins/shared/build-progress.gradle") // Matches the path only when GAME_STACK_DIR is followed by one, which skips the // build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and // the glob for gradle's output, none of which are inputs this tar has to carry. diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index b4458f8..55b2fa9 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -2,10 +2,14 @@ package main import ( "context" + "io" + "os" "strings" "time" + "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" ) // summaryModel is the terminal screen of the setup wizard. On a first run it @@ -24,9 +28,14 @@ type summaryModel struct { alreadySetUp bool // re-run: Owner pre-existed localHint bool // show the self-signed-cert note // alerts is where the watchdog's alerts go; nil leaves the rows out. - alerts *alertRoute + alerts *alertRoute + copyText func(string) error + copyNotice string + copyFailed bool } +type summaryCopiedMsg struct{ err error } + func (m *summaryModel) Init() tea.Cmd { return nil } // arrowNavOK lets the root repurpose ←/→ to walk back through completed steps; @@ -34,9 +43,35 @@ func (m *summaryModel) Init() tea.Cmd { return nil } func (m *summaryModel) arrowNavOK() bool { return true } func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if copied, ok := msg.(summaryCopiedMsg); ok { + m.copyFailed = copied.err != nil + switch { + case copied.err != nil: + m.copyNotice = "Could not copy: " + copied.err.Error() + case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "": + m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support." + default: + m.copyNotice = "Link copied to clipboard." + } + return m, nil + } if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "c", "C": + link := m.panelURL + if m.setupTokenURL != "" { + link = m.setupTokenURL + } + if link == "" { + m.copyNotice, m.copyFailed = "No link to copy.", true + return m, nil + } + copyText := m.copyText + if copyText == nil { + copyText = copyTerminalText + } + return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} } + case "n", "N": return m, func() tea.Msg { return reconfigureConnectMsg{} } case "s", "S": return m, func() tea.Msg { return reconfigureStorageMsg{} } @@ -101,10 +136,40 @@ func (m *summaryModel) View() string { b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") } - b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit")) + if m.copyNotice != "" { + style := tuiOK + if m.copyFailed { + style = tuiWarn + } + b.WriteString("\n" + style.Render(m.copyNotice) + "\n") + } + copyLabel := "copy panel link" + if m.setupTokenURL != "" { + copyLabel = "copy setup link" + } + b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit")) return b.String() } +// SSH copies through the terminal; the remote host's desktop clipboard is unrelated. +// /dev/tty keeps the one-time link out of redirected stdout and install logs. +func copyTerminalText(text string) error { + if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" { + return clipboard.WriteAll(text) + } + tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + return err + } + defer tty.Close() + sequence := ansi.SetSystemClipboard(text) + if os.Getenv("TMUX") != "" { + sequence = ansi.TmuxPassthrough(sequence) + } + _, err = io.WriteString(tty, sequence) + return err +} + // alertRoute is where this host's watchdog alerts go, as the summary shows it: // by mail through the [smtp] relay to the Owners' verified addresses, and the // heartbeat that notices the host itself going down (docs/troubleshooting.md diff --git a/cmd/felis/tui_summary_test.go b/cmd/felis/tui_summary_test.go new file mode 100644 index 0000000..1d6181c --- /dev/null +++ b/cmd/felis/tui_summary_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestSummaryCopyLink(t *testing.T) { + t.Setenv("SSH_TTY", "") + t.Setenv("SSH_CONNECTION", "") + for _, tc := range []struct { + name, setup, want string + }{ + {"first login", "https://panel.example/setup?token=test-token", "https://panel.example/setup?token=test-token"}, + {"already set up", "", "https://panel.example"}, + } { + t.Run(tc.name, func(t *testing.T) { + var copied string + m := &summaryModel{ + panelURL: "https://panel.example", setupTokenURL: tc.setup, + copyText: func(text string) error { copied = text; return nil }, + } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + if cmd == nil { + t.Fatal("copy did not return a command") + } + msg := cmd() + if _, ok := msg.(summaryCopiedMsg); !ok { + t.Fatalf("c returned %T; must copy, not reconfigure", msg) + } + m.Update(msg) + if copied != tc.want || m.copyFailed || !strings.Contains(m.View(), "Link copied to clipboard.") { + t.Fatalf("copied %q, notice %q", copied, m.copyNotice) + } + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if _, ok := cmd().(reconfigureConnectMsg); !ok { + t.Fatal("n must still allow changing the connection") + } + }) + } +} + +func TestSummaryCopyFailureAndSSHFeedback(t *testing.T) { + t.Setenv("SSH_TTY", "/dev/pts/1") + m := &summaryModel{ + panelURL: "https://panel.example", + copyText: func(string) error { return errors.New("clipboard unavailable") }, + } + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("C")}) + m.Update(cmd()) + if !m.copyFailed || !strings.Contains(m.View(), "clipboard unavailable") { + t.Fatal("copy failure must be visible") + } + m.Update(summaryCopiedMsg{}) + if m.copyFailed || !strings.Contains(m.View(), "Copy sent to terminal") || strings.Contains(m.View(), "Link copied to clipboard") { + t.Fatal("OSC 52 has no confirmation; do not claim the local clipboard was updated") + } + if _, cmd := (&summaryModel{}).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}); cmd != nil { + t.Fatal("a missing link must not trigger a clipboard write") + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index b11efa8..3c5e7f2 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -3017,7 +3017,7 @@ EOF chmod 0755 "${tmp}/felis" log "building ${FELIS_IMAGE} from the current felis binary" - docker build -t "$FELIS_IMAGE" "$tmp" + docker build --progress=plain -t "$FELIS_IMAGE" "$tmp" rm -rf "$tmp" } @@ -3031,7 +3031,7 @@ build_image_from_source() { # Without the stamp main.version stays "dev", and `felis update` refuses to compare a # "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image # is not a cosmetic problem: it silently disables update reporting for the install. - docker build -t "$FELIS_IMAGE" \ + docker build --progress=plain -t "$FELIS_IMAGE" \ --build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR" log "extracting the felis binary onto the host (${HOST_BIN})" @@ -3504,7 +3504,7 @@ build_game_image() { limbo) img="$FELIS_LIMBO_IMAGE" log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})" - docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ + docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \ --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \ --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \ --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \ @@ -3515,7 +3515,7 @@ build_game_image() { lobby) img="$FELIS_LOBBY_IMAGE" log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)" - docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ + docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \ @@ -3527,7 +3527,7 @@ build_game_image() { # operator initContainer's job, so this image carries no /menu plugin and no secret gate. img="$FELIS_PAPER_IMAGE" log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)" - docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ + docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \ --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \ --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \ -t "$img" "$GAME_STACK_DIR" @@ -3633,7 +3633,7 @@ build_velocity_plugin() { docker run --rm \ -v "${GAME_STACK_DIR}:/src:z" \ -w /src/plugins/velocity \ - "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \ + "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle \ || die "felis-velocity plugin build failed" local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar ) [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \ diff --git a/deploy/build-release-artifacts.sh b/deploy/build-release-artifacts.sh index 379da84..e94ea8a 100755 --- a/deploy/build-release-artifacts.sh +++ b/deploy/build-release-artifacts.sh @@ -126,7 +126,7 @@ mkdir -p "${WORK}/velocity" tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \ -v "${WORK}/velocity:/src" -w /src/plugins/velocity \ - "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build + "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar ) [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar" install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar" diff --git a/deploy/limbo/Dockerfile b/deploy/limbo/Dockerfile index 6d1d0dd..442227e 100644 --- a/deploy/limbo/Dockerfile +++ b/deploy/limbo/Dockerfile @@ -51,7 +51,7 @@ RUN if [ -z "${LIMBO_VERSION:-}" ]; then \ echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \ fi \ && cd plugins/limbo \ - && gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \ + && gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$LIMBO_VERSION" build \ && cp build/libs/*.jar /felis-limbo.jar # ---- assemble the runtime ---- diff --git a/deploy/lobby/Dockerfile b/deploy/lobby/Dockerfile index 4f8f729..d479ee5 100644 --- a/deploy/lobby/Dockerfile +++ b/deploy/lobby/Dockerfile @@ -40,7 +40,7 @@ WORKDIR /src COPY plugins/paper/ ./plugins/paper/ COPY plugins/shared/ ./plugins/shared/ RUN cd plugins/paper \ - && gradle --no-daemon build \ + && gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle build \ && cp build/libs/*.jar /felis-paper.jar # ---- assemble the runtime ---- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9be9adc..dd07cae 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2320,6 +2320,13 @@ about … MiB under /var/lib/containerd, and … has … MiB free; nothing has b once the asset downloads (the messages above it say which one failed), free that space, or set `FELIS_PREFLIGHT=warn` to build anyway **[SH-TESTED]**. +Host builds keep Docker's output in the terminal scrollback. During plugin builds, +`[felis] Downloading ()` identifies each Gradle dependency download; +every ten seconds an active download reports bytes, speed and elapsed time. A +percentage appears only when the server supplies a total size. After thirty seconds +without new bytes the line says `no new data for …; waiting for the download`; +this reports a wait, without changing Gradle's timeout or retry behavior. + | Message | Meaning | What to do | |---|---|---| | `release vX publishes no SHA256SUMS … building them on this host instead` | the release predates release assets, or release.yml is still uploading them | nothing for an old release; for a new one, rerun once the release page lists `SHA256SUMS` | @@ -2747,7 +2754,7 @@ host yourself, plus the off-site encryption key if the copy is in the bucket. `cloudflared-felis` unit stay behind with the old disk, so the panel hostnames answer Cloudflare error 1033 until a connector runs here. - Run `sudo felis setup`, press `c` (change connection) on the status + Run `sudo felis setup`, press `n` (change connection) on the status screen and choose Cloudflare Tunnel + Access. On the step's first screen press `i` to install cloudflared, then `l` for `cloudflared tunnel login` (browser consent on your account, which writes `cert.pem`); `enter` opens diff --git a/go.mod b/go.mod index ef95d5a..0f39f4e 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.26 require ( github.com/BurntSushi/toml v1.6.0 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/descope/virtualwebauthn v1.0.5 github.com/go-logr/logr v1.4.2 github.com/go-webauthn/webauthn v0.17.4 @@ -27,13 +29,11 @@ require ( require ( github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/plugins/shared/build-progress.gradle b/plugins/shared/build-progress.gradle new file mode 100644 index 0000000..6beb2f0 --- /dev/null +++ b/plugins/shared/build-progress.gradle @@ -0,0 +1,74 @@ +import org.gradle.api.logging.Logging +import org.gradle.internal.operations.BuildOperationListener +import org.gradle.internal.operations.BuildOperationListenerManager +import org.gradle.internal.operations.OperationProgressDetails +import org.gradle.internal.resource.ExternalResourceReadBuildOperationType +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +// The Docker images pin Gradle. Observe its download events without changing requests, +// dependency verification or caching; plain console output otherwise hides these events. +def logger = Logging.getLogger('felis.download') +def downloads = new ConcurrentHashMap() +def bytes = { long value -> + value >= 1024 * 1024 ? String.format(Locale.ROOT, '%.1f MiB', value / (1024.0 * 1024)) : + String.format(Locale.ROOT, '%.1f KiB', value / 1024.0) +} +def seconds = { long since -> TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - since) } +def listener = [ + started: { operation, event -> + if (!(operation.details instanceof ExternalResourceReadBuildOperationType.Details)) return + def uri = URI.create(operation.details.location) + if (!(uri.scheme in ['http', 'https'])) return + def now = System.nanoTime() + def name = "${uri.path.tokenize('/').last()} (${uri.host})" + downloads[operation.id] = [name: name, bytes: 0L, total: 0L, started: now, + changed: now, sampled: now, sampledBytes: 0L] + logger.lifecycle("[felis] Downloading ${name}") + }, + progress: { id, event -> + def download = downloads[id] + if (download == null || !(event.details instanceof OperationProgressDetails)) return + if (event.details.units != 'bytes') return + synchronized (download) { + if (event.details.progress > download.bytes) download.changed = System.nanoTime() + download.bytes = event.details.progress + download.total = event.details.total + } + }, + finished: { operation, event -> + def download = downloads.remove(operation.id) + if (download == null) return + def outcome = event.failure != null ? 'Download failed' : + event.result?.missing ? 'Not found' : 'Downloaded' + logger.lifecycle("[felis] ${outcome} ${download.name} (${seconds(download.started)}s)") + } +] as BuildOperationListener + +def manager = gradle.services.get(BuildOperationListenerManager) +manager.addListener(listener) +def timer = Executors.newSingleThreadScheduledExecutor({ task -> + def thread = new Thread(task, 'felis-download-progress') + thread.daemon = true + thread +} as java.util.concurrent.ThreadFactory) +timer.scheduleAtFixedRate({ + downloads.values().each { download -> + synchronized (download) { + def now = System.nanoTime() + def rate = (long) ((download.bytes - download.sampledBytes) * 1e9 / (now - download.sampled)) + def total = download.total > 0 ? bytes(download.total) : 'unknown size' + def percent = download.total > 0 ? " (${Math.min(100, (100L * download.bytes).intdiv(download.total))}%)" : '' + def idle = seconds(download.changed) + def waiting = idle >= 30 ? " | no new data for ${idle}s; waiting for the download" : '' + logger.lifecycle("[felis] ${download.name}: ${bytes(download.bytes)} / ${total}${percent} | ${bytes(rate)}/s | ${seconds(download.started)}s elapsed${waiting}") + download.sampled = now + download.sampledBytes = download.bytes + } + } +} as Runnable, 10, 10, TimeUnit.SECONDS) +gradle.buildFinished { + timer.shutdownNow() + manager.removeListener(listener) +} diff --git a/plugins/test.sh b/plugins/test.sh index e79e343..e48d8a1 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -226,7 +226,7 @@ java -cp "$work/linkcard-classes:$adventure_api:$adventure_key:$examination_api" for module in velocity paper; do echo "==> plugins/$module: ./gradlew --no-daemon build" - ( cd "plugins/$module" && ./gradlew --no-daemon build ) + ( cd "plugins/$module" && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle build ) done echo "==> plugins/velocity: ./gradlew --no-daemon routingTest" @@ -238,4 +238,4 @@ echo "==> plugins/paper: ./gradlew --no-daemon lobbyTest" limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)" [ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; } echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest" -( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build loginTest ) +( cd plugins/limbo && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$limbo_version" build loginTest )