Compare commits

..

57 Commits
v0.0.3 ... main

Author SHA1 Message Date
Rob Watson
add511e3dd refactor: extract commands to domain package
Some checks are pending
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
ci-build / lint (push) Waiting to run
ci-scan / Analyze (go) (push) Waiting to run
ci-scan / Analyze (actions) (push) Waiting to run
2025-04-20 20:55:10 +02:00
Rob Watson
7afa84505e fix(mediaserver): handle custom hostname with self-signed certs 2025-04-20 18:59:27 +02:00
Rob Watson
4a863a3212 feat(mediaserver): custom TLS certs 2025-04-20 11:27:17 +02:00
Rob Watson
98d93ad286 feat(mediaserver): RTMPS
Some checks are pending
ci-build / lint (push) Waiting to run
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
ci-scan / Analyze (go) (push) Waiting to run
ci-scan / Analyze (actions) (push) Waiting to run
2025-04-19 22:05:55 +02:00
Rob Watson
5f026be769 refactor(config)!: update config file schema
BREAKING CHANGE: YAML schema
2025-04-18 18:00:28 +02:00
Rob Watson
d35dedb15b test(integration): fix typo 2025-04-18 09:59:40 +02:00
Rob Watson
e49bbb6800 refactor(mediaserver): simplify API interactions
Some checks are pending
ci-build / lint (push) Waiting to run
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
ci-scan / Analyze (go) (push) Waiting to run
ci-scan / Analyze (actions) (push) Waiting to run
2025-04-18 09:51:56 +02:00
Rob Watson
c022c18a7f doc: add CONTRIBUTING.md 2025-04-17 11:28:25 +02:00
Rob Watson
b147da6d9b feat(mediaserver): configurable RTMP host and bind address 2025-04-17 11:15:08 +02:00
Rob Watson
e113d55044 build: fix homebrew tap permissions 2025-04-17 07:29:02 +02:00
Rob Watson
0df42511ce doc: add skeleton SECURITY.md
Some checks failed
ci-build / release (push) Has been cancelled
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-scan / Analyze (go) (push) Has been cancelled
ci-scan / Analyze (actions) (push) Has been cancelled
2025-04-17 07:06:10 +02:00
Rob Watson
f853a5cced build: rename workflows 2025-04-17 07:06:10 +02:00
Rob Watson
781d535d38 doc: update README 2025-04-17 06:48:48 +02:00
Rob Watson
f3a5b802b8 build: add CodeQL 2025-04-17 06:48:48 +02:00
Rob Watson
6646402273 chore: bump golang.org/x/crypto 2025-04-17 06:48:04 +02:00
Rob Watson
f6c87c4568 build: set default permissions 2025-04-17 06:47:19 +02:00
Rob Watson
2f980acbb3 chore: bump dependencies 2025-04-17 06:47:17 +02:00
Rob Watson
b8389eceb0 feat: improve Docker connection error handling 2025-04-16 14:53:47 +02:00
Rob Watson
f2b9e9af75 doc: update README 2025-04-16 10:44:40 +02:00
Rob Watson
8eca4b0e27 build: homebrew tap 2025-04-16 10:31:54 +02:00
Rob Watson
b05ae25809 fix(ui): allow multiple destination error modals
Some checks failed
ci-build / release (push) Has been cancelled
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
2025-04-15 09:23:01 +02:00
Rob Watson
52b0616d5f chore(config): rename streamKey field 2025-04-15 08:11:53 +02:00
Rob Watson
55e04e0249 doc: update README 2025-04-15 08:11:53 +02:00
Rob Watson
df9724afa7 feat: container logs
Some checks are pending
ci-build / lint (push) Waiting to run
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
2025-04-14 11:17:10 +01:00
Rob Watson
579dfeef22 test(integration): extract app startup logic 2025-04-13 11:31:00 +02:00
Rob Watson
5f6b6fa3a9 test(integration): add t.Helper() 2025-04-13 11:00:18 +02:00
Rob Watson
c4bc32b3e8 refactor(container): restart handling
Some checks failed
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-build / release (push) Has been cancelled
- display error modal if container exits in first 15 seconds
- restart containers manually
- allow more flexible restart policies
- extend integration tests
2025-04-13 09:26:31 +01:00
Rob Watson
f7f9843c4b test(integration): add coverage for custom stream key 2025-04-11 09:57:45 +02:00
Rob Watson
b69e1ecf86 ci: fix go test parallelism flag
Ref: https://medium.com/@emil.musician/the-ultimate-guide-to-parallel-testing-in-go-p-parallel-and-t-parallel-demystified-50bb6ca07046
2025-04-11 09:44:58 +02:00
Rob Watson
524dafd800 security(mediaserver): bind to 127.0.0.1 2025-04-11 09:28:30 +02:00
Rob Watson
b257f456ba feat(config): tighten RTMP URL validation
Some checks failed
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-build / release (push) Has been cancelled
2025-04-10 22:00:37 +02:00
Rob Watson
045498a2ce fix(ui): handle table wraparound on k binding
Some checks are pending
ci-build / lint (push) Waiting to run
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
2025-04-10 07:38:05 +02:00
Rob Watson
08a5690d30 test: add container debug logs 2025-04-10 07:38:05 +02:00
Rob Watson
d332a78af1 fix(ui): further key handling improvements
Avoids losing user destination selection when re-rendering the page,
especially after adding or removing a destination.
2025-04-10 07:37:24 +02:00
Rob Watson
f791125c02 feat(ui): update key bindings 2025-04-09 21:05:06 +02:00
Rob Watson
30da888184 feat(ui): improve error handling on startup 2025-04-09 06:50:35 +02:00
Rob Watson
2fbf2176cf feat(ui): improve key handling
- handle add-destination input in own handler func
- handle CTRL-C when modal is visible
- fix destination wraparound on key-up
2025-04-08 13:39:17 +02:00
Rob Watson
af4410c4cf feat(ui): prefer "source" to "ingress" 2025-04-08 13:06:43 +02:00
Rob Watson
18d5ef3db1 fix(container): do not error on pull image failure 2025-04-08 13:03:29 +02:00
Rob Watson
1e759b6f42 fix: print help on -h flag 2025-04-06 16:58:33 +02:00
Rob Watson
3523a1a34e feat(mediaserver): update MediaMTX image
Some checks failed
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-build / release (push) Has been cancelled
2025-04-06 09:25:50 +02:00
Rob Watson
5735b2d73a refactor: consistent naming 2025-04-06 06:43:25 +02:00
Rob Watson
0fbbe25e1b refactor: extract test helpers 2025-04-06 06:43:25 +02:00
Rob Watson
1c742426ed chore: fix typo 2025-04-05 21:42:36 +02:00
Rob Watson
e778c3c443 fix(ui): mediaserver error modal improvements 2025-04-05 21:42:36 +02:00
Rob Watson
e14cfdee85 refactor: startup check
Some checks failed
ci-build / lint (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-build / release (push) Has been cancelled
- separate mediaserver create and start
- avoid blocking main app loop during startup check
- remove ui.allowQuit
- add integration test
2025-04-05 09:43:11 +02:00
Rob Watson
266a9307d2 fix(config): ensure log file path is set
Fix a bug introduced in 6952516 which led to the app being unable to
start if logging was enabled but no explicit path was set. In this case,
the expected behaviour is to fallback to a log file in the XDG file
hierarchy, but this was lost due to broken config file defaults
handling.

This commit separates the behaviour when setting defaults when reading
an existing configuration, from those set when creating a brand new
configuration.
2025-04-04 20:49:05 +02:00
Rob Watson
cd2c339c10 fix(ui): close UI from main goroutine
This seems to have been unnecessary and can actually cause the app to
hang on closing if ctrl-c is pressed when the "add destinations" page is
visible.
2025-04-04 15:59:34 +02:00
Rob Watson
54cfe3a55f feat(ui): add "no destinations" page 2025-04-04 15:59:34 +02:00
Rob Watson
87f6786387 chore: bump Go to 1.24.2 2025-04-03 20:10:48 +02:00
Rob Watson
2eeb809e6e chore(ui): update copy 2025-04-03 20:10:36 +02:00
Rob Watson
522be621ee refactor: replicator 2025-04-03 17:23:27 +02:00
Rob Watson
6952516204 chore: config fixes
- remove example config file
- don't set default values on read
- omit empty fields
- indent yaml 2 spaces
2025-04-01 21:06:31 +02:00
Rob Watson
ba356137c3 chore: enable Docker API version negotiation 2025-04-01 21:03:05 +02:00
Rob Watson
b05bc6f98b fix: avoid data race during app shutdown 2025-04-01 19:18:22 +02:00
Rob Watson
34f18b0fbd build: disable changelog sort 2025-04-01 19:12:42 +02:00
Rob Watson
5bf19f92b0 security(mediaserver): stop leaking credentials to Docker events log 2025-04-01 17:48:34 +02:00
48 changed files with 3212 additions and 1146 deletions

View File

@ -10,11 +10,15 @@ on:
jobs: jobs:
lint: lint:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ludeeus/action-shellcheck@2.0.0 - uses: ludeeus/action-shellcheck@2.0.0
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
needs: needs:
- lint - lint
steps: steps:
@ -35,10 +39,10 @@ jobs:
ffmpeg-version: release ffmpeg-version: release
- name: checkout - name: checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Go 1.24.1 - name: Setup Go 1.24.2
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.24.1' go-version: '1.24.2'
cache: false cache: false
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
@ -67,10 +71,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go 1.24.1 - name: Setup Go 1.24.2
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.24.1' go-version: '1.24.2'
- name: install OS dependencies - name: install OS dependencies
run: | run: |
sudo apt-get -y update && \ sudo apt-get -y update && \
@ -83,3 +87,4 @@ jobs:
args: release --clean args: release --clean
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

89
.github/workflows/codeql.yml vendored Normal file
View File

@ -0,0 +1,89 @@
name: ci-scan
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '36 23 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@ -26,9 +26,26 @@ archives:
- goos: windows - goos: windows
formats: [zip] formats: [zip]
brews:
- name: octoplex
description: "Octoplex is a live video restreamer for the terminal."
homepage: "https://github.com/rfwatson/octoplex"
repository:
owner: rfwatson
name: homebrew-octoplex
token: "{{ .Env.HOMEBREW_TOKEN }}"
install: |
bin.install "octoplex"
test: |
system "#{bin}/octoplex -h"
release:
github:
owner: rfwatson
name: octoplex
changelog: changelog:
use: github use: github
sort: asc
filters: filters:
exclude: exclude:
- "^doc:" - "^doc:"

43
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,43 @@
# Contributing
Thanks for contributing to Octoplex!
## Development
### Mise
Octoplex uses [mise](https://mise.jdx.dev/installing-mise.html) as a task
runner and environment management tool.
Once installed, you can run common development tasks easily:
Command|Shortcut|Description
---|---|---
`mise run test`|`mise run t`|Run unit tests
`mise run test_integration`|`mise run ti`|Run integration tests
`mise run lint`|`mise run l`|Run linter
`mise run format`|`mise run f`|Run formatter
`mise run generate_mocks`|`mise run m`|Re-generate mocks
### Tests
#### Integration tests
The integration tests (mostly in `/internal/app/integration_test.go`) attempt
to exercise the entire app, including launching containers and rendering the
terminal output.
Sometimes they can be flaky. Always ensure there are no stale Docker containers
present from previous runs, and that nothing is listening or attempting to
broadcast to localhost:1935 or localhost:1936.
## Opening a pull request
Pull requests are welcome, but please propose significant changes in a
[discussion](https://github.com/rfwatson/octoplex/discussions) first.
1. Fork the repo
2. Make your changes, including test coverage
3. Push the changes to a branch
4. Ensure the branch is passing
5. Open a pull request

153
README.md
View File

@ -1,6 +1,153 @@
# Octoplex :octopus: # Octoplex :octopus:
Octoplex multiplexes RTMP streams to multiple destinations from the ![build status](https://github.com/rfwatson/octoplex/actions/workflows/build.yml/badge.svg)
comfort of your terminal. ![scan status](https://github.com/rfwatson/octoplex/actions/workflows/codeql.yml/badge.svg)
![GitHub Release](https://img.shields.io/github/v/release/rfwatson/octoplex)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
![build status](https://github.com/rfwatson/octoplex/actions/workflows/ci-build.yml/badge.svg) Octoplex is a live video restreamer for the terminal.
* Restream RTMP/RTMPS to unlimited destinations
* Broadcast using OBS and other standard tools
* Add and remove destinations while streaming
* Automatic reconnections
* Terminal user interface with real-time container metrics and health status
* Built on FFmpeg, Docker and other proven free software
## How it works
```
+------------------+ +-------------------+
| OBS | ----> | Octoplex |
| (Video Capture) | RTMP | |
+------------------+ +-------------------+
|
| Restream to multiple destinations
v
+------------+ +------------+ +------------+ +--------------+
| Twitch.tv | | YouTube | | Facebook | | Other |
+------------+ +------------+ +------------+ | Destinations |
+--------------+
```
## Asciicast :video_camera:
[![asciicast](https://asciinema.org/a/Es8hpa6rq82ov7cDM6bZTVyCT.svg)](https://asciinema.org/a/Es8hpa6rq82ov7cDM6bZTVyCT)
## Installation
### Docker Engine
First, make sure Docker Engine is installed. Octoplex uses Docker to manage
FFmpeg and other streaming tools.
Linux: See https://docs.docker.com/engine/install/.
MacOS: https://docs.docker.com/desktop/setup/install/mac-install/
### Octoplex
#### Homebrew
Octoplex can be installed using Homebrew on MacOS or Linux.
```
$ brew tap rfwatson/octoplex
$ brew install octoplex
```
#### From Github
Alternatively, grab the latest build for your platform from the [releases page](https://github.com/rfwatson/octoplex/releases).
Unarchive the `octoplex` binary and copy it somewhere in your $PATH.
## Usage
Launch the `octoplex` binary.
```
$ octoplex
```
### Connecting with OBS
To connect with OBS, configure it to stream to `rtmp://localhost:1935/live`.
![OBS streaming settings](/assets/obs1.png)
### Subcommands
Subcommand|Description
---|---
None|Launch the terminal user interface
`print-config`|Echo the path to the configuration file to STDOUT
`edit-config`|Edit the configuration file in $EDITOR
`version`|Print the version
`help`|Print help screen
### Configuration file
Octoplex stores configuration state in a simple YAML file. (See [above](#subcommands) for its location.)
Sample configuration:
```yaml
logfile:
enabled: true # defaults to false
path: /path/to/logfile # defaults to $XDG_STATE_HOME/octoplex/octoplex.log
sources:
mediaServer:
streamKey: live # defaults to "live"
host: rtmp.example.com # defaults to "localhost"
tls: # optional. If RTMPS is enabled, defaults to a
cert: /etc/mycert.pem # self-signed keypair corresponding to the host
key: /etc/mykey.pem # key.
rtmp:
enabled: true # defaults to false
ip: 127.0.0.1 # defaults to 127.0.0.1
port: 1935 # defaults to 1935
rtmps:
enabled: true # defaults to false
ip: 0.0.0.0 # defaults to 127.0.0.1
port: 1936 # defaults to 1936
destinations:
- name: YouTube # Destination name, used only for display
url: rtmp://rtmp.youtube.com/12345 # Destination URL with stream key
- name: Twitch.tv
url: rtmp://rtmp.youtube.com/12345
# other destinations here
```
:information_source: It is also possible to add and remove destinations directly from the
terminal user interface.
:warning: `sources.mediaServer.rtmp.ip` must be set to a valid IP address if
you want to accept connections from other hosts. Leave it blank to bind only to
localhost (`127.0.0.1`) or use `0.0.0.0` to bind to all network interfaces.
## Contributing
### Bug reports
Open bug reports [on GitHub](https://github.com/rfwatson/octoplex/issues/new).
### Pull requests
Pull requests are welcome.
## Acknowledgements
Octoplex is built on and/or makes use of other free and open source software,
most notably:
Name|License|URL
---|---|---
Docker|`Apache 2.0`|[GitHub](https://github.com/moby/moby/tree/master/client)
FFmpeg|`LGPL`|[Website](https://www.ffmpeg.org/legal.html)
MediaMTX|`MIT`|[GitHub](https://github.com/bluenviron/mediamtx)
tview|`MIT`|[GitHub](https://github.com/rivo/tview)
## Licence
Octoplex is released under the [AGPL v3](https://github.com/rfwatson/octoplex/blob/main/LICENSE) license.

11
SECURITY.md Normal file
View File

@ -0,0 +1,11 @@
# Security
## Supported versions
Octoplex is currently alpha software. Security updates will be targeted to the
`main` branch.
## Reporting a vulnerability
Please report any vulnerability privately through GitHub:
https://github.com/rfwatson/octoplex/security

BIN
assets/obs1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,11 +0,0 @@
FROM bluenviron/mediamtx:latest AS mediamtx
FROM alpine:3.21
RUN apk add --no-cache \
bash \
curl
COPY --from=mediamtx /mediamtx /usr/bin/mediamtx
CMD ["/usr/bin/mediamtx"]

23
go.mod
View File

@ -3,11 +3,12 @@ module git.netflux.io/rob/octoplex
go 1.24.0 go 1.24.0
require ( require (
github.com/docker/docker v28.0.1+incompatible github.com/docker/docker v28.0.4+incompatible
github.com/docker/go-connections v0.5.0 github.com/docker/go-connections v0.5.0
github.com/gdamore/tcell/v2 v2.8.1 github.com/gdamore/tcell/v2 v2.8.1
github.com/google/go-cmp v0.7.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/rivo/tview v0.0.0-20250330220935-949945f8d922
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/testcontainers/testcontainers-go v0.35.0 github.com/testcontainers/testcontainers-go v0.35.0
golang.design/x/clipboard v0.7.0 golang.design/x/clipboard v0.7.0
@ -87,18 +88,18 @@ require (
go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.37.0 // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.25.0 // indirect golang.org/x/image v0.26.0 // indirect
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de // indirect golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.30.0 // indirect golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.9.0 // indirect golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.31.0 // indirect golang.org/x/tools v0.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )

48
go.sum
View File

@ -26,8 +26,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@ -125,8 +125,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= github.com/rivo/tview v0.0.0-20250330220935-949945f8d922 h1:SMyqkaRfpE8ZQUSRTZKO3uN84xov++OGa+e3NCksaQw=
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= github.com/rivo/tview v0.0.0-20250330220935-949945f8d922/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@ -219,16 +219,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de h1:WuckfUoaRGJfaQTPZvlmcaQwg4Xj9oS2cvvh3dUqpDo= golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 h1:8MGTx39304caZ/OMsjPfuxUoDGI2tRas92F5x97tIYc=
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de/go.mod h1:/IZuixag1ELW37+FftdmIt59/3esqpAWM/QqWtf7HUI= golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7/go.mod h1:ftACcHgQ7vaOnQbHOHvXt9Y6bEPHrs5Ovk67ClwrPJA=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -249,8 +249,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -260,8 +260,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -283,8 +283,8 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -294,8 +294,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -305,8 +305,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -317,8 +317,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -12,8 +12,9 @@ import (
"git.netflux.io/rob/octoplex/internal/container" "git.netflux.io/rob/octoplex/internal/container"
"git.netflux.io/rob/octoplex/internal/domain" "git.netflux.io/rob/octoplex/internal/domain"
"git.netflux.io/rob/octoplex/internal/mediaserver" "git.netflux.io/rob/octoplex/internal/mediaserver"
"git.netflux.io/rob/octoplex/internal/multiplexer" "git.netflux.io/rob/octoplex/internal/replicator"
"git.netflux.io/rob/octoplex/internal/terminal" "git.netflux.io/rob/octoplex/internal/terminal"
"github.com/docker/docker/client"
) )
// RunParams holds the parameters for running the application. // RunParams holds the parameters for running the application.
@ -37,6 +38,11 @@ func Run(ctx context.Context, params RunParams) error {
state := new(domain.AppState) state := new(domain.AppState)
applyConfig(cfg, state) applyConfig(cfg, state)
// Ensure there is at least one active source.
if !cfg.Sources.MediaServer.RTMP.Enabled && !cfg.Sources.MediaServer.RTMPS.Enabled {
return errors.New("config: either sources.mediaServer.rtmp.enabled or sources.mediaServer.rtmps.enabled must be set")
}
logger := params.Logger logger := params.Logger
ui, err := terminal.StartUI(ctx, terminal.StartParams{ ui, err := terminal.StartUI(ctx, terminal.StartParams{
Screen: params.Screen, Screen: params.Screen,
@ -50,8 +56,31 @@ func Run(ctx context.Context, params RunParams) error {
} }
defer ui.Close() defer ui.Close()
// emptyUI is a dummy function that sets the UI state to an empty state, and
// re-renders the screen.
//
// This is a workaround for a weird interaction between tview and
// tcell.SimulationScreen which leads to newly-added pages not rendering if
// the UI is not re-rendered for a second time.
// It is only needed for integration tests when rendering modals before the
// main loop starts. It would be nice to remove this but the risk/impact on
// non-test code is pretty low.
emptyUI := func() { ui.SetState(domain.AppState{}) }
containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client")) containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
if err != nil { if err != nil {
err = fmt.Errorf("create container client: %w", err)
var errString string
if client.IsErrConnectionFailed(err) {
errString = "Could not connect to Docker. Is Docker installed and running?"
} else {
errString = err.Error()
}
ui.ShowFatalErrorModal(errString)
emptyUI()
<-ui.C()
return err return err
} }
defer containerClient.Close() defer containerClient.Close()
@ -59,54 +88,64 @@ func Run(ctx context.Context, params RunParams) error {
updateUI := func() { ui.SetState(*state) } updateUI := func() { ui.SetState(*state) }
updateUI() updateUI()
var exists bool var tlsCertPath, tlsKeyPath string
if exists, err = containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil { if cfg.Sources.MediaServer.TLS != nil {
return fmt.Errorf("check existing containers: %w", err) tlsCertPath = cfg.Sources.MediaServer.TLS.CertPath
} else if exists { tlsKeyPath = cfg.Sources.MediaServer.TLS.KeyPath
if ui.ShowStartupCheckModal() {
if err = containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil {
return fmt.Errorf("remove existing containers: %w", err)
}
if err = containerClient.RemoveUnusedNetworks(ctx); err != nil {
return fmt.Errorf("remove unused networks: %w", err)
}
} else {
return nil
}
}
ui.AllowQuit()
// While RTMP is the only source, it doesn't make sense to disable it.
if !cfg.Sources.RTMP.Enabled {
return errors.New("config: sources.rtmp.enabled must be set to true")
} }
srv, err := mediaserver.StartActor(ctx, mediaserver.StartActorParams{ srv, err := mediaserver.NewActor(ctx, mediaserver.NewActorParams{
StreamKey: mediaserver.StreamKey(cfg.Sources.RTMP.StreamKey), RTMPAddr: buildNetAddr(cfg.Sources.MediaServer.RTMP),
RTMPSAddr: buildNetAddr(cfg.Sources.MediaServer.RTMPS),
Host: cfg.Sources.MediaServer.Host,
TLSCertPath: tlsCertPath,
TLSKeyPath: tlsKeyPath,
StreamKey: mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey),
ContainerClient: containerClient, ContainerClient: containerClient,
Logger: logger.With("component", "mediaserver"), Logger: logger.With("component", "mediaserver"),
}) })
if err != nil { if err != nil {
return fmt.Errorf("start mediaserver: %w", err) err = fmt.Errorf("create mediaserver: %w", err)
ui.ShowFatalErrorModal(err.Error())
emptyUI()
<-ui.C()
return err
} }
defer srv.Close() defer srv.Close()
mp := multiplexer.NewActor(ctx, multiplexer.NewActorParams{ // Set the RTMP and RTMPS URLs in the UI, which are only known after the
SourceURL: srv.State().RTMPInternalURL, // MediaServer is available.
ui.SetRTMPURLs(srv.RTMPURL(), srv.RTMPSURL())
repl := replicator.StartActor(ctx, replicator.StartActorParams{
SourceURL: srv.RTMPInternalURL(),
ContainerClient: containerClient, ContainerClient: containerClient,
Logger: logger.With("component", "multiplexer"), Logger: logger.With("component", "replicator"),
}) })
defer mp.Close() defer repl.Close()
const uiUpdateInterval = time.Second const uiUpdateInterval = time.Second
uiUpdateT := time.NewTicker(uiUpdateInterval) uiUpdateT := time.NewTicker(uiUpdateInterval)
defer uiUpdateT.Stop() defer uiUpdateT.Stop()
startupCheckC := doStartupCheck(ctx, containerClient, ui.ShowStartupCheckModal)
for { for {
select { select {
case cfg = <-params.ConfigService.C(): case err := <-startupCheckC:
applyConfig(cfg, state) if errors.Is(err, errStartupCheckUserQuit) {
updateUI() return nil
} else if err != nil {
return fmt.Errorf("startup check: %w", err)
} else {
startupCheckC = nil
if err = srv.Start(ctx); err != nil {
return fmt.Errorf("start mediaserver: %w", err)
}
}
case <-params.ConfigService.C():
// No-op, config updates are handled synchronously for now.
case cmd, ok := <-ui.C(): case cmd, ok := <-ui.C():
if !ok { if !ok {
// TODO: keep UI open until all containers have closed // TODO: keep UI open until all containers have closed
@ -116,7 +155,7 @@ func Run(ctx context.Context, params RunParams) error {
logger.Debug("Command received", "cmd", cmd.Name()) logger.Debug("Command received", "cmd", cmd.Name())
switch c := cmd.(type) { switch c := cmd.(type) {
case terminal.CommandAddDestination: case domain.CommandAddDestination:
newCfg := cfg newCfg := cfg
newCfg.Destinations = append(newCfg.Destinations, config.Destination{ newCfg.Destinations = append(newCfg.Destinations, config.Destination{
Name: c.DestinationName, Name: c.DestinationName,
@ -127,9 +166,11 @@ func Run(ctx context.Context, params RunParams) error {
ui.ConfigUpdateFailed(err) ui.ConfigUpdateFailed(err)
continue continue
} }
cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationAdded() ui.DestinationAdded()
case terminal.CommandRemoveDestination: case domain.CommandRemoveDestination:
mp.StopDestination(c.URL) // no-op if not live repl.StopDestination(c.URL) // no-op if not live
newCfg := cfg newCfg := cfg
newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool { newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool {
return dest.URL == c.URL return dest.URL == c.URL
@ -139,16 +180,19 @@ func Run(ctx context.Context, params RunParams) error {
ui.ConfigUpdateFailed(err) ui.ConfigUpdateFailed(err)
continue continue
} }
case terminal.CommandStartDestination: cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationRemoved()
case domain.CommandStartDestination:
if !state.Source.Live { if !state.Source.Live {
ui.ShowSourceNotLiveModal() ui.ShowSourceNotLiveModal()
continue continue
} }
mp.StartDestination(c.URL) repl.StartDestination(c.URL)
case terminal.CommandStopDestination: case domain.CommandStopDestination:
mp.StopDestination(c.URL) repl.StopDestination(c.URL)
case terminal.CommandQuit: case domain.CommandQuit:
return nil return nil
} }
case <-uiUpdateT.C: case <-uiUpdateT.C:
@ -157,12 +201,12 @@ func Run(ctx context.Context, params RunParams) error {
logger.Debug("Server state received", "state", serverState) logger.Debug("Server state received", "state", serverState)
applyServerState(serverState, state) applyServerState(serverState, state)
updateUI() updateUI()
case mpState := <-mp.C(): case replState := <-repl.C():
logger.Debug("Multiplexer state received", "state", mpState) logger.Debug("Replicator state received", "state", replState)
destErrors := applyMultiplexerState(mpState, state) destErrors := applyReplicatorState(replState, state)
for _, destError := range destErrors { for _, destError := range destErrors {
handleDestError(destError, mp, ui) handleDestError(destError, repl, ui)
} }
updateUI() updateUI()
@ -170,6 +214,12 @@ func Run(ctx context.Context, params RunParams) error {
} }
} }
// handleConfigUpdate applies the config to the app state, and updates the UI.
func handleConfigUpdate(cfg config.Config, appState *domain.AppState, ui *terminal.UI) {
applyConfig(cfg, appState)
ui.SetState(*appState)
}
// applyServerState applies the current server state to the app state. // applyServerState applies the current server state to the app state.
func applyServerState(serverState domain.Source, appState *domain.AppState) { func applyServerState(serverState domain.Source, appState *domain.AppState) {
appState.Source = serverState appState.Source = serverState
@ -183,29 +233,29 @@ type destinationError struct {
err error err error
} }
// applyMultiplexerState applies the current multiplexer state to the app state. // applyReplicatorState applies the current replicator state to the app state.
// //
// It returns a list of destination errors that should be displayed to the user. // It returns a list of destination errors that should be displayed to the user.
func applyMultiplexerState(mpState multiplexer.State, appState *domain.AppState) []destinationError { func applyReplicatorState(replState replicator.State, appState *domain.AppState) []destinationError {
var errorsToDisplay []destinationError var errorsToDisplay []destinationError
for i := range appState.Destinations { for i := range appState.Destinations {
dest := &appState.Destinations[i] dest := &appState.Destinations[i]
if dest.URL != mpState.URL { if dest.URL != replState.URL {
continue continue
} }
if dest.Container.Err == nil && mpState.Container.Err != nil { if dest.Container.Err == nil && replState.Container.Err != nil {
errorsToDisplay = append(errorsToDisplay, destinationError{ errorsToDisplay = append(errorsToDisplay, destinationError{
name: dest.Name, name: dest.Name,
url: dest.URL, url: dest.URL,
err: mpState.Container.Err, err: replState.Container.Err,
}) })
} }
dest.Container = mpState.Container dest.Container = replState.Container
dest.Status = mpState.Status dest.Status = replState.Status
break break
} }
@ -214,10 +264,10 @@ func applyMultiplexerState(mpState multiplexer.State, appState *domain.AppState)
} }
// handleDestError displays a modal to the user, and stops the destination. // handleDestError displays a modal to the user, and stops the destination.
func handleDestError(destError destinationError, mp *multiplexer.Actor, ui *terminal.UI) { func handleDestError(destError destinationError, repl *replicator.Actor, ui *terminal.UI) {
ui.ShowDestinationErrorModal(destError.name, destError.err) ui.ShowDestinationErrorModal(destError.name, destError.err)
mp.StopDestination(destError.url) repl.StopDestination(destError.url)
} }
// applyConfig applies the config to the app state. For now we only set the // applyConfig applies the config to the app state. For now we only set the
@ -248,3 +298,48 @@ func resolveDestinations(destinations []domain.Destination, inDestinations []con
return destinations[:len(inDestinations)] return destinations[:len(inDestinations)]
} }
var errStartupCheckUserQuit = errors.New("user quit startup check modal")
// doStartupCheck performs a startup check to see if there are any existing app
// containers.
//
// It returns a channel that will be closed, possibly after receiving an error.
// If the error is non-nil the app must not be started. If the error is
// [errStartupCheckUserQuit], the user voluntarily quit the startup check
// modal.
func doStartupCheck(ctx context.Context, containerClient *container.Client, showModal func() bool) <-chan error {
ch := make(chan error, 1)
go func() {
defer close(ch)
if exists, err := containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil {
ch <- fmt.Errorf("check existing containers: %w", err)
} else if exists {
if showModal() {
if err = containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil {
ch <- fmt.Errorf("remove existing containers: %w", err)
return
}
if err = containerClient.RemoveUnusedNetworks(ctx); err != nil {
ch <- fmt.Errorf("remove unused networks: %w", err)
return
}
} else {
ch <- errStartupCheckUserQuit
}
}
}()
return ch
}
// buildNetAddr builds a [mediaserver.OptionalNetAddr] from the config.
func buildNetAddr(src config.RTMPSource) mediaserver.OptionalNetAddr {
if !src.Enabled {
return mediaserver.OptionalNetAddr{Enabled: false}
}
return mediaserver.OptionalNetAddr{Enabled: true, NetAddr: domain.NetAddr(src.NetAddr)}
}

View File

@ -0,0 +1,204 @@
//go:build integration
package app_test
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"
"git.netflux.io/rob/octoplex/internal/app"
"git.netflux.io/rob/octoplex/internal/config"
"git.netflux.io/rob/octoplex/internal/container"
"git.netflux.io/rob/octoplex/internal/domain"
"git.netflux.io/rob/octoplex/internal/terminal"
"github.com/gdamore/tcell/v2"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
)
func buildAppParams(
t *testing.T,
configService *config.Service,
dockerClient container.DockerClient,
screen tcell.SimulationScreen,
screenCaptureC chan<- terminal.ScreenCapture,
logger *slog.Logger,
) app.RunParams {
t.Helper()
return app.RunParams{
ConfigService: configService,
DockerClient: dockerClient,
Screen: &terminal.Screen{
Screen: screen,
Width: 180,
Height: 25,
CaptureC: screenCaptureC,
},
ClipboardAvailable: false,
BuildInfo: domain.BuildInfo{Version: "0.0.1", GoVersion: "go1.16.3"},
Logger: logger,
}
}
func setupSimulationScreen(t *testing.T) (tcell.SimulationScreen, chan<- terminal.ScreenCapture, func() []string) {
t.Helper()
// Fetching the screen contents is tricky at this level of the test pyramid,
// because we need to:
//
// 1. Somehow capture the screen contents, which is only available via the
// tcell.SimulationScreen, and...
// 2. Do so without triggering data races.
//
// We can achieve this by passing a channel into the terminal actor, which
// will send screen captures after each render. This can be stored locally
// and asserted against when needed.
var (
screenCells []tcell.SimCell
screenWidth int
screenMu sync.Mutex
)
getContents := func() []string {
screenMu.Lock()
defer screenMu.Unlock()
var lines []string
for n, _ := range screenCells {
y := n / screenWidth
if y > len(lines)-1 {
lines = append(lines, "")
}
if len(screenCells[n].Runes) == 0 { // shouldn't really happen unless there is no output
continue
}
lines[y] += string(screenCells[n].Runes[0])
}
require.GreaterOrEqual(t, len(lines), 5, "Screen contents should have at least 5 lines")
return lines
}
t.Cleanup(func() {
if t.Failed() {
printScreen(t, getContents, "After failing")
}
})
screen := tcell.NewSimulationScreen("")
screenCaptureC := make(chan terminal.ScreenCapture, 1)
go func() {
for {
select {
case <-t.Context().Done():
return
case capture := <-screenCaptureC:
screenMu.Lock()
screenCells = capture.Cells
screenWidth = capture.Width
screenMu.Unlock()
}
}
}()
return screen, screenCaptureC, getContents
}
func contentsIncludes(contents []string, search string) bool {
for _, line := range contents {
if strings.Contains(line, search) {
return true
}
}
return false
}
func setupConfigService(t *testing.T, cfg config.Config) *config.Service {
t.Helper()
tmpDir, err := os.MkdirTemp("", "octoplex_"+strings.ReplaceAll(t.Name(), "/", "_"))
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(tmpDir) })
configService, err := config.NewService(func() (string, error) { return tmpDir, nil }, 1)
require.NoError(t, err)
require.NoError(t, configService.SetConfig(cfg))
return configService
}
func printScreen(t *testing.T, getContents func() []string, label string) {
t.Helper()
fmt.Println(label + ":")
for _, line := range getContents() {
fmt.Println(line)
}
}
func sendKey(t *testing.T, screen tcell.SimulationScreen, key tcell.Key, ch rune) {
t.Helper()
screen.InjectKey(key, ch, tcell.ModNone)
time.Sleep(50 * time.Millisecond)
}
func sendKeys(t *testing.T, screen tcell.SimulationScreen, keys string) {
t.Helper()
screen.InjectKeyBytes([]byte(keys))
time.Sleep(500 * time.Millisecond)
}
func sendBackspaces(t *testing.T, screen tcell.SimulationScreen, n int) {
t.Helper()
for range n {
screen.InjectKey(tcell.KeyBackspace, ' ', tcell.ModNone)
time.Sleep(50 * time.Millisecond)
}
time.Sleep(500 * time.Millisecond)
}
// kickFirstRTMPConn kicks the first RTMP connection from the mediaMTX server.
func kickFirstRTMPConn(t *testing.T, srv testcontainers.Container) {
t.Helper()
type conn struct {
ID string `json:"id"`
}
type apiResponse struct {
Items []conn `json:"items"`
}
port, err := srv.MappedPort(t.Context(), "9997/tcp")
require.NoError(t, err)
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/v3/rtmpconns/list", port.Int()))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var apiResp apiResponse
require.NoError(t, json.Unmarshal(respBody, &apiResp))
require.NoError(t, err)
require.True(t, len(apiResp.Items) > 0, "No RTMP connections found")
resp, err = http.Post(fmt.Sprintf("http://localhost:%d/v3/rtmpconns/kick/%s", port.Int(), apiResp.Items[0].ID), "application/json", nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
}

File diff suppressed because it is too large Load Diff

12
internal/app/testdata/mediamtx.yml vendored Normal file
View File

@ -0,0 +1,12 @@
rtmp: true
rtmpAddress: :1936
api: true
authInternalUsers:
- user: any
ips: []
permissions:
- action: api
- action: read
- action: publish
paths:
live:

17
internal/app/testdata/openssl.cnf vendored Normal file
View File

@ -0,0 +1,17 @@
# openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt -config openssl.cnf
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
x509_extensions = v3_req
[dn]
CN = localhost
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost

18
internal/app/testdata/server.crt vendored Normal file
View File

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC7TCCAdWgAwIBAgIUTeqv46R19q+BS2e4DBkbIHuWyIIwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDQyMDA4NTMwN1oXDTM1MDQx
ODA4NTMwN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA0v/KndfKfG8XItStHeMQ/3z1r8vhkH9KGpfSwDMp8MdH
Mox6vcAsIIr1RFKmalQQg+T+TK9v3XM6F4sJ+WPyb5/31xLUqG6zivitrMy1AZ8w
XLgAz/CTufXL3OBntDwg29QXWt9lOUJyjRa66AQqreTlItuLG65bswfPA4g35f+U
hyr49paukqnVHRr44GtyiNxlfYCEdQWdOR0EQmZ7y6WNQQhnR8odQyftR2lykf17
MSJ8us4JAgZ2fr1QR+DfX5bCSS/WJ2aO7xxeES40NizBx08qYFami1zXrGMMo35I
SfedCohcok8ZZ1oWL+MfSJ2OLVclDnznDPTx39pZPQIDAQABozcwNTAUBgNVHREE
DTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFCgZah+m2NXkI9biS2vnhNUrd3FiMA0G
CSqGSIb3DQEBCwUAA4IBAQAPbofZIKCm3DnudFnK+LRkdlpMNOyH2zn3g8h8vrfL
Tfi0oBgHb7EYxcHYDanZbcIKracWCfQVze2FRLgNFBWiyhDO4IXe/LpwSnbyLWCh
psbGuyVmEz9CuiyVdIi+CWQs5dBBRUCFg6NE2/r6Diw9LD0fVCVUwkvqopetfp1B
tvA74O0RduLWs+iXNs5XW4sODVkrOmhBbRrP9GRCVqiqVWJka6CzrNdBm0Y9zZMQ
GD/6fEgDaW8YlShoO+e4FwmD2IgIx+m4xamr/cQkWpbOHMxAwv7vP0stfkpyUacW
dh9eJmsDAmgGgdtMJvbIfyR9ilG8D6zwOmSlkF6fDJ3E
-----END CERTIFICATE-----

28
internal/app/testdata/server.key vendored Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDS/8qd18p8bxci
1K0d4xD/fPWvy+GQf0oal9LAMynwx0cyjHq9wCwgivVEUqZqVBCD5P5Mr2/dczoX
iwn5Y/Jvn/fXEtSobrOK+K2szLUBnzBcuADP8JO59cvc4Ge0PCDb1Bda32U5QnKN
FrroBCqt5OUi24sbrluzB88DiDfl/5SHKvj2lq6SqdUdGvjga3KI3GV9gIR1BZ05
HQRCZnvLpY1BCGdHyh1DJ+1HaXKR/XsxIny6zgkCBnZ+vVBH4N9flsJJL9YnZo7v
HF4RLjQ2LMHHTypgVqaLXNesYwyjfkhJ950KiFyiTxlnWhYv4x9InY4tVyUOfOcM
9PHf2lk9AgMBAAECggEAC3E3qaukHW9gz9C8upwvtcsu/6OMzes5N4v4L9gWdCo6
YDFiDpw3SGSAvH3G7Ik2hBCNAdeZt2aiRdiSZ+XVpdwE8rLguWmXbvfhYzeOsVHS
q5SG5r/jIviDX60DsrB4D7PGuHTY5mwGDkSnSiG/tsJs8qD5QD0KWAEaZtSiQ2Sp
kcRbdq13/2tjHyx7nBxEYUFC4EJQjK3cNNV4G7nG2xcfT46uPvFV0+1CQtMpFYhi
IsGaSBhW9gOAheycYxCi+LRdUh1IAnLUyYUenu0o8PoXsHp6KD8eS5RXtfA6THd/
Jr614gdAB2Sffw+bFf6FIBNWa5Jwsg9UtbGtjNdo+QKBgQDrOJ2nj7El6MIqeDHs
1cCeGDKmjB1CYWALLHrwwiwmrvEoeBMiJuMN4epZdQw9hwExa7fNpERI7Ay8s5HD
cdppxgcW7CWChNncbVZ39P+YI9URWC2Q2Y8FBhc9FA0sKpDak0rf5UE63SGjU8/I
FGgwjd1Ln5wws00OsYXBZw1lzwKBgQDlo2kRy6xvrUNAbeggT9OQeg2SdkWqvS3v
NUhBzZkVhJNf1oApNRoAvRMQt+Xt+Euw1pQ+TvdOZQhhqxs/pD/wGdM7rhq9r0+G
itsQ5LvNCxCePbSkbFMLgC8JgNuM3aRqhtsU+Illk9xvCj2nKsd+UUN3NxYgjCqa
evTKSzUfMwKBgFapy1w7EteWxEMFec96ibc1zyORqA4W9l3ni3w87itqdSul4dbJ
YQpyW/eNqm7Y2NWujE/V39rGLYMw3dmWjxQ9g8ssQj2uWN5f4mXb/He/a/cx98fQ
gGMndVRpmNjW7fu6HPIU802Ov5//dySOcDzDZ+8+5TsENLXfLhqtrz/9AoGBALc+
/BQoTFTdlSHv0mEecjwDOZtbZ+KEjggpo5xm/TbPkW7T03eOmU5nkrQvm3qXPYdC
5A8Ioo5bTyHpEZhqcF8frJEeMNaW88XwPjmv3TEVGFC9+s2OZ4Jw6pgRzKEPKSmc
rWyBm9qD8E5nhKVGHOVu4YBbY/va/hBB998Jvr1DAoGBAK5nnswLyQZi0lgpkl1P
ITkmvnQlZBfuqvoD7wcQ3nx/K/mdacsxepRne+U/4+iNzRtd3gU0iccCWUTJl4aB
cFRW1eXWuff+4vmM4JToDevGPXrS0CHE20mATJRZPH+YjZFl0pFSc4/tnjxBnx4y
vgM382WU9N9jIHCCnM6DYsbK
-----END PRIVATE KEY-----

View File

@ -1,5 +1,7 @@
package config package config
import "cmp"
// Destination holds the configuration for a destination. // Destination holds the configuration for a destination.
type Destination struct { type Destination struct {
Name string `yaml:"name"` Name string `yaml:"name"`
@ -9,18 +11,48 @@ type Destination struct {
// LogFile holds the configuration for the log file. // LogFile holds the configuration for the log file.
type LogFile struct { type LogFile struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Path string `yaml:"path"` Path string `yaml:"path,omitempty"`
defaultPath string
}
// GetPath returns the path to the log file. If the path is not set, it
// returns the default log path.
func (l LogFile) GetPath() string {
return cmp.Or(l.Path, l.defaultPath)
}
// NetAddr holds an IP and/or port.
type NetAddr struct {
IP string `yaml:"ip,omitempty"`
Port int `yaml:"port,omitempty"`
} }
// RTMPSource holds the configuration for the RTMP source. // RTMPSource holds the configuration for the RTMP source.
type RTMPSource struct { type RTMPSource struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
StreamKey string `yaml:"streamkey"`
NetAddr `yaml:",inline"`
}
// TLS holds the TLS configuration.
type TLS struct {
CertPath string `yaml:"cert,omitempty"`
KeyPath string `yaml:"key,omitempty"`
}
// MediaServerSource holds the configuration for the media server source.
type MediaServerSource struct {
StreamKey string `yaml:"streamKey,omitempty"`
Host string `yaml:"host,omitempty"`
TLS *TLS `yaml:"tls,omitempty"`
RTMP RTMPSource `yaml:"rtmp"`
RTMPS RTMPSource `yaml:"rtmps"`
} }
// Sources holds the configuration for the sources. // Sources holds the configuration for the sources.
type Sources struct { type Sources struct {
RTMP RTMPSource `yaml:"rtmp"` MediaServer MediaServerSource `yaml:"mediaServer"`
} }
// Config holds the configuration for the application. // Config holds the configuration for the application.

View File

@ -1,24 +0,0 @@
# Octoplex is a live stream multiplexer.
---
#
sources:
# Currently the only source type is RTMP server.
rtmp:
enabled: yes
# Your local stream key. Defaults to "live".
#
# rtmp://localhost:1935/live
streamkey: live
#
logfile:
# Change to yes to log to system location.
enabled: no
# Or, log to this absolute path:
# path: octoplex.log
#
# Define your destinations here.
destinations:
# - name: YouTube
# url: rtmp://rtmp.youtube.com/myYoutubeStreamKey
# - name: Twitch
# url: rtmp://ingest.global-contribute.live-video.net/app/myTwitchStreamKey

View File

@ -1,20 +1,17 @@
package config package config
import ( import (
"bytes"
_ "embed" _ "embed"
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"git.netflux.io/rob/octoplex/internal/domain"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
//go:embed data/config.example.yml
var exampleConfig []byte
// Service provides configuration services. // Service provides configuration services.
type Service struct { type Service struct {
current Config current Config
@ -50,6 +47,7 @@ func NewService(configDirFunc ConfigDirFunc, chanSize int) (*Service, error) {
return nil, fmt.Errorf("app config dir: %w", err) return nil, fmt.Errorf("app config dir: %w", err)
} }
// TODO: inject StateDirFunc
appStateDir, err := createAppStateDir() appStateDir, err := createAppStateDir()
if err != nil { if err != nil {
return nil, fmt.Errorf("app state dir: %w", err) return nil, fmt.Errorf("app state dir: %w", err)
@ -61,7 +59,7 @@ func NewService(configDirFunc ConfigDirFunc, chanSize int) (*Service, error) {
configC: make(chan Config, chanSize), configC: make(chan Config, chanSize),
} }
svc.setDefaults(&svc.current) svc.populateConfigOnBuild(&svc.current)
return svc, nil return svc, nil
} }
@ -100,7 +98,7 @@ func (s *Service) SetConfig(cfg Config) error {
return fmt.Errorf("validate: %w", err) return fmt.Errorf("validate: %w", err)
} }
cfgBytes, err := yaml.Marshal(cfg) cfgBytes, err := marshalConfig(cfg)
if err != nil { if err != nil {
return fmt.Errorf("marshal: %w", err) return fmt.Errorf("marshal: %w", err)
} }
@ -130,8 +128,7 @@ func (s *Service) readConfig() (cfg Config, _ error) {
return cfg, fmt.Errorf("unmarshal: %w", err) return cfg, fmt.Errorf("unmarshal: %w", err)
} }
s.setDefaults(&cfg) s.populateConfigOnRead(&cfg)
if err = validate(cfg); err != nil { if err = validate(cfg); err != nil {
return cfg, err return cfg, err
} }
@ -143,17 +140,31 @@ func (s *Service) readConfig() (cfg Config, _ error) {
func (s *Service) writeDefaultConfig() (Config, error) { func (s *Service) writeDefaultConfig() (Config, error) {
var cfg Config var cfg Config
if err := yaml.Unmarshal(exampleConfig, &cfg); err != nil { s.populateConfigOnBuild(&cfg)
return cfg, fmt.Errorf("unmarshal: %w", err)
cfgBytes, err := marshalConfig(cfg)
if err != nil {
return cfg, fmt.Errorf("marshal: %w", err)
} }
if err := s.writeConfig(exampleConfig); err != nil { if err := s.writeConfig(cfgBytes); err != nil {
return Config{}, fmt.Errorf("write config: %w", err) return Config{}, fmt.Errorf("write config: %w", err)
} }
return cfg, nil return cfg, nil
} }
func marshalConfig(cfg Config) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(cfg); err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
return buf.Bytes(), nil
}
func (s *Service) writeConfig(cfgBytes []byte) error { func (s *Service) writeConfig(cfgBytes []byte) error {
if err := os.MkdirAll(s.appConfigDir, 0744); err != nil { if err := os.MkdirAll(s.appConfigDir, 0744); err != nil {
return fmt.Errorf("mkdir: %w", err) return fmt.Errorf("mkdir: %w", err)
@ -166,18 +177,27 @@ func (s *Service) writeConfig(cfgBytes []byte) error {
return nil return nil
} }
func (s *Service) setDefaults(cfg *Config) { // populateConfigOnBuild is called to set default values for a new, empty
if cfg.LogFile.Enabled && cfg.LogFile.Path == "" { // configuration.
cfg.LogFile.Path = filepath.Join(s.appStateDir, domain.AppName+".log") //
// This function may set serialized fields to arbitrary values.
func (s *Service) populateConfigOnBuild(cfg *Config) {
cfg.Sources.MediaServer.StreamKey = "live"
cfg.Sources.MediaServer.RTMP = RTMPSource{
Enabled: true,
NetAddr: NetAddr{IP: "127.0.0.1", Port: 1935},
} }
cfg.Sources.RTMP.Enabled = true s.populateConfigOnRead(cfg)
}
for i := range cfg.Destinations { // populateConfigOnRead is called to set default values for a configuration
if strings.TrimSpace(cfg.Destinations[i].Name) == "" { // read from an existing file.
cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1) //
} // This function should not update any serialized values, which would be a
} // confusing experience for the user.
func (s *Service) populateConfigOnRead(cfg *Config) {
cfg.LogFile.defaultPath = filepath.Join(s.appStateDir, "octoplex.log")
} }
// TODO: validate URL format // TODO: validate URL format
@ -187,8 +207,10 @@ func validate(cfg Config) error {
urlCounts := make(map[string]int) urlCounts := make(map[string]int)
for _, dest := range cfg.Destinations { for _, dest := range cfg.Destinations {
if !strings.HasPrefix(dest.URL, "rtmp://") { if u, urlErr := url.Parse(dest.URL); urlErr != nil {
err = errors.Join(err, fmt.Errorf("destination URL must start with rtmp://")) err = errors.Join(err, fmt.Errorf("invalid destination URL: %w", urlErr))
} else if u.Scheme != "rtmp" {
err = errors.Join(err, errors.New("destination URL must be an RTMP URL"))
} }
urlCounts[dest.URL]++ urlCounts[dest.URL]++

View File

@ -9,24 +9,30 @@ import (
"git.netflux.io/rob/octoplex/internal/config" "git.netflux.io/rob/octoplex/internal/config"
"git.netflux.io/rob/octoplex/internal/shortid" "git.netflux.io/rob/octoplex/internal/shortid"
gocmp "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
) )
//go:embed testdata/complete.yml //go:embed testdata/complete.yml
var configComplete []byte var configComplete []byte
//go:embed testdata/no-logfile.yml //go:embed testdata/rtmps-only.yml
var configNoLogfile []byte var configRTMPSOnly []byte
//go:embed testdata/logfile.yml //go:embed testdata/logfile.yml
var configLogfile []byte var configLogfile []byte
//go:embed testdata/no-name.yml //go:embed testdata/no-logfile.yml
var configNoName []byte var configNoLogfile []byte
//go:embed testdata/invalid-destination-url.yml //go:embed testdata/destination-url-not-rtmp.yml
var configInvalidDestinationURL []byte var configDestinationURLNotRTMP []byte
//go:embed testdata/destination-url-not-valid.yml
var configDestinationURLNotValid []byte
//go:embed testdata/multiple-invalid-destination-urls.yml //go:embed testdata/multiple-invalid-destination-urls.yml
var configMultipleInvalidDestinationURLs []byte var configMultipleInvalidDestinationURLs []byte
@ -41,7 +47,9 @@ func TestConfigServiceCurrent(t *testing.T) {
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) }) t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
// Ensure defaults are set: // Ensure defaults are set:
assert.True(t, service.Current().Sources.RTMP.Enabled) assert.NotNil(t, service.Current().Sources.MediaServer.RTMP)
assert.Equal(t, "127.0.0.1", service.Current().Sources.MediaServer.RTMP.IP)
assert.Equal(t, 1935, service.Current().Sources.MediaServer.RTMP.Port)
} }
func TestConfigServiceCreateConfig(t *testing.T) { func TestConfigServiceCreateConfig(t *testing.T) {
@ -55,13 +63,18 @@ func TestConfigServiceCreateConfig(t *testing.T) {
cfg, err := service.ReadOrCreateConfig() cfg, err := service.ReadOrCreateConfig()
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, cfg.LogFile, "expected no log file") require.False(t, cfg.LogFile.Enabled, "expected logging to be disabled")
require.Empty(t, cfg.LogFile.Path, "expected no log file")
p := filepath.Join(systemConfigDir, "octoplex", "config.yaml") p := filepath.Join(systemConfigDir, "octoplex", "config.yaml")
cfgBytes, err := os.ReadFile(p) cfgBytes, err := os.ReadFile(p)
require.NoError(t, err, "config file was not created") require.NoError(t, err, "config file was not created")
// Ensure the example config file is written:
assert.Contains(t, string(cfgBytes), "# Octoplex is a live stream multiplexer.") var readCfg config.Config
require.NoError(t, yaml.Unmarshal(cfgBytes, &readCfg))
assert.NotNil(t, readCfg.Sources.MediaServer.RTMP)
assert.Equal(t, "127.0.0.1", readCfg.Sources.MediaServer.RTMP.IP)
assert.Equal(t, 1935, readCfg.Sources.MediaServer.RTMP.Port)
} }
func TestConfigServiceReadConfig(t *testing.T) { func TestConfigServiceReadConfig(t *testing.T) {
@ -75,33 +88,76 @@ func TestConfigServiceReadConfig(t *testing.T) {
name: "complete", name: "complete",
configBytes: configComplete, configBytes: configComplete,
want: func(t *testing.T, cfg config.Config) { want: func(t *testing.T, cfg config.Config) {
require.Equal( require.Empty(
t, t,
config.Config{ gocmp.Diff(
LogFile: config.LogFile{ config.Config{
Enabled: true, LogFile: config.LogFile{
Path: "test.log", Enabled: true,
}, Path: "test.log",
Sources: config.Sources{ },
RTMP: config.RTMPSource{ Sources: config.Sources{
Enabled: true, MediaServer: config.MediaServerSource{
StreamKey: "s3cr3t", StreamKey: "s3cr3t",
Host: "rtmp.example.com",
TLS: &config.TLS{
CertPath: "/etc/cert.pem",
KeyPath: "/etc/key.pem",
},
RTMP: config.RTMPSource{
Enabled: true,
NetAddr: config.NetAddr{
IP: "0.0.0.0",
Port: 19350,
},
},
RTMPS: config.RTMPSource{
Enabled: true,
NetAddr: config.NetAddr{
IP: "0.0.0.0",
Port: 19443,
},
},
},
},
Destinations: []config.Destination{
{
Name: "my stream",
URL: "rtmp://rtmp.example.com:1935/live",
},
}, },
}, },
Destinations: []config.Destination{ cfg,
{ cmpopts.IgnoreUnexported(config.LogFile{}),
Name: "my stream", ),
URL: "rtmp://rtmp.example.com:1935/live", )
},
},
}, cfg)
}, },
}, },
{ {
name: "logging enabled, no logfile", name: "RTMPS only",
configBytes: configNoLogfile, configBytes: configRTMPSOnly,
want: func(t *testing.T, cfg config.Config) { want: func(t *testing.T, cfg config.Config) {
assert.True(t, strings.HasSuffix(cfg.LogFile.Path, "/octoplex/octoplex.log")) require.Empty(
t,
gocmp.Diff(
config.Config{
LogFile: config.LogFile{Enabled: true},
Sources: config.Sources{
MediaServer: config.MediaServerSource{
RTMPS: config.RTMPSource{
Enabled: true,
NetAddr: config.NetAddr{
IP: "0.0.0.0",
Port: 1935,
},
},
},
},
},
cfg,
cmpopts.IgnoreUnexported(config.LogFile{}),
),
)
}, },
}, },
{ {
@ -112,21 +168,26 @@ func TestConfigServiceReadConfig(t *testing.T) {
}, },
}, },
{ {
name: "no name", name: "logging enabled, no logfile",
configBytes: configNoName, configBytes: configNoLogfile,
want: func(t *testing.T, cfg config.Config) { want: func(t *testing.T, cfg config.Config) {
assert.Equal(t, "Stream 1", cfg.Destinations[0].Name) assert.True(t, strings.HasSuffix(cfg.LogFile.GetPath(), "/octoplex/octoplex.log"), "expected %q to end with /tmp/octoplex.log", cfg.LogFile.GetPath())
}, },
}, },
{ {
name: "invalid destination URL", name: "destination URL is not rtmp scheme",
configBytes: configInvalidDestinationURL, configBytes: configDestinationURLNotRTMP,
wantErr: "destination URL must start with rtmp://", wantErr: "destination URL must be an RTMP URL",
},
{
name: "destination URL is not valid",
configBytes: configDestinationURLNotValid,
wantErr: `invalid destination URL: parse "rtmp://rtmp.example.com/%%+": invalid URL escape "%%+"`,
}, },
{ {
name: "multiple invalid destination URLs", name: "multiple invalid destination URLs",
configBytes: configMultipleInvalidDestinationURLs, configBytes: configMultipleInvalidDestinationURLs,
wantErr: "destination URL must start with rtmp://\ndestination URL must start with rtmp://", wantErr: "destination URL must be an RTMP URL\ndestination URL must be an RTMP URL",
}, },
} }

View File

@ -3,9 +3,20 @@ logfile:
enabled: true enabled: true
path: test.log path: test.log
sources: sources:
rtmp: mediaServer:
enabled: true streamKey: s3cr3t
streamkey: s3cr3t host: rtmp.example.com
tls:
cert: /etc/cert.pem
key: /etc/key.pem
rtmp:
enabled: true
ip: 0.0.0.0
port: 19350
rtmps:
enabled: true
ip: 0.0.0.0
port: 19443
destinations: destinations:
- name: my stream - name: my stream
url: rtmp://rtmp.example.com:1935/live url: rtmp://rtmp.example.com:1935/live

View File

@ -3,4 +3,4 @@ logfile:
enabled: true enabled: true
path: test.log path: test.log
destinations: destinations:
- url: rtmp://rtmp.example.com:1935/live - url: rtmp://rtmp.example.com/%%+

View File

@ -0,0 +1,9 @@
---
logfile:
enabled: true
sources:
mediaServer:
rtmps:
enabled: true
ip: 0.0.0.0
port: 1935

View File

@ -21,7 +21,6 @@ import (
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
) )
@ -38,8 +37,8 @@ type DockerClient interface {
io.Closer io.Closer
ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
ContainerRemove(context.Context, string, container.RemoveOptions) error ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerStart(context.Context, string, container.StartOptions) error ContainerStart(context.Context, string, container.StartOptions) error
ContainerStats(context.Context, string, bool) (container.StatsResponseReader, error) ContainerStats(context.Context, string, bool) (container.StatsResponseReader, error)
@ -72,6 +71,7 @@ type Client struct {
wg sync.WaitGroup wg sync.WaitGroup
apiClient DockerClient apiClient DockerClient
networkID string networkID string
cancelFuncs map[string]context.CancelFunc
pulledImages map[string]struct{} pulledImages map[string]struct{}
logger *slog.Logger logger *slog.Logger
} }
@ -99,6 +99,7 @@ func NewClient(ctx context.Context, apiClient DockerClient, logger *slog.Logger)
cancel: cancel, cancel: cancel,
apiClient: apiClient, apiClient: apiClient,
networkID: network.ID, networkID: network.ID,
cancelFuncs: make(map[string]context.CancelFunc),
pulledImages: make(map[string]struct{}), pulledImages: make(map[string]struct{}),
logger: logger, logger: logger,
} }
@ -136,17 +137,53 @@ func (a *Client) getEvents(containerID string) <-chan events.Message {
return ch return ch
} }
// getLogs returns a channel (which is never closed) that will receive
// container logs.
func (a *Client) getLogs(ctx context.Context, containerID string, cfg LogConfig) <-chan []byte {
if !cfg.Stdout && !cfg.Stderr {
return nil
}
ch := make(chan []byte)
go getLogs(ctx, containerID, a.apiClient, cfg, ch, a.logger)
return ch
}
// NetworkCountConfig holds configuration for observing network traffic.
type NetworkCountConfig struct { type NetworkCountConfig struct {
Rx string // the network name to count the Rx bytes Rx string // the network name to count the Rx bytes
Tx string // the network name to count the Tx bytes Tx string // the network name to count the Tx bytes
} }
// CopyFileConfig holds configuration for a single file which should be copied
// into a container.
type CopyFileConfig struct { type CopyFileConfig struct {
Path string Path string
Payload io.Reader Payload io.Reader
Mode int64 Mode int64
} }
// LogConfig holds configuration for container logs.
type LogConfig struct {
Stdout, Stderr bool
}
// ShouldRestartFunc is a callback function that is called when a container
// exits. It should return true if the container is to be restarted. If not
// restarting, err may be non-nil.
type ShouldRestartFunc func(
exitCode int64,
restartCount int,
containerLogs [][]byte,
runningTime time.Duration,
) (bool, error)
// defaultRestartInterval is the default interval between restarts.
// TODO: exponential backoff
const defaultRestartInterval = 10 * time.Second
// RunContainerParams are the parameters for running a container. // RunContainerParams are the parameters for running a container.
type RunContainerParams struct { type RunContainerParams struct {
Name string Name string
@ -155,7 +192,10 @@ type RunContainerParams struct {
HostConfig *container.HostConfig HostConfig *container.HostConfig
NetworkingConfig *network.NetworkingConfig NetworkingConfig *network.NetworkingConfig
NetworkCountConfig NetworkCountConfig NetworkCountConfig NetworkCountConfig
CopyFileConfigs []CopyFileConfig CopyFiles []CopyFileConfig
Logs LogConfig
ShouldRestart ShouldRestartFunc
RestartInterval time.Duration // defaults to 10 seconds
} }
// RunContainer runs a container with the given parameters. // RunContainer runs a container with the given parameters.
@ -164,13 +204,18 @@ type RunContainerParams struct {
// never be closed. The error channel will receive an error if the container // never be closed. The error channel will receive an error if the container
// fails to start, and will be closed when the container exits, possibly after // fails to start, and will be closed when the container exits, possibly after
// receiving an error. // receiving an error.
//
// Panics if ShouldRestart is non-nil and the host config defines a restart
// policy of its own.
func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<-chan domain.Container, <-chan error) { func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<-chan domain.Container, <-chan error) {
if params.ShouldRestart != nil && !params.HostConfig.RestartPolicy.IsNone() {
panic("shouldRestart and restart policy are mutually exclusive")
}
now := time.Now() now := time.Now()
containerStateC := make(chan domain.Container, cmp.Or(params.ChanSize, defaultChanSize)) containerStateC := make(chan domain.Container, cmp.Or(params.ChanSize, defaultChanSize))
errC := make(chan error, 1) errC := make(chan error, 1)
sendError := func(err error) { sendError := func(err error) { errC <- err }
errC <- err
}
a.wg.Add(1) a.wg.Add(1)
go func() { go func() {
@ -178,9 +223,7 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
defer close(errC) defer close(errC)
if err := a.pullImageIfNeeded(ctx, params.ContainerConfig.Image, containerStateC); err != nil { if err := a.pullImageIfNeeded(ctx, params.ContainerConfig.Image, containerStateC); err != nil {
a.logger.Error("Error pulling image", "err", err) a.logger.Warn("Error pulling image", "err", err)
sendError(fmt.Errorf("image pull: %w", err))
return
} }
containerConfig := *params.ContainerConfig containerConfig := *params.ContainerConfig
@ -213,7 +256,7 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
return return
} }
if err = a.copyFilesToContainer(ctx, createResp.ID, params.CopyFileConfigs); err != nil { if err = a.copyFilesToContainer(ctx, createResp.ID, params.CopyFiles); err != nil {
sendError(fmt.Errorf("copy files to container: %w", err)) sendError(fmt.Errorf("copy files to container: %w", err))
return return
} }
@ -226,11 +269,21 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
containerStateC <- domain.Container{ID: createResp.ID, Status: domain.ContainerStatusRunning} containerStateC <- domain.Container{ID: createResp.ID, Status: domain.ContainerStatusRunning}
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
a.mu.Lock()
a.cancelFuncs[createResp.ID] = cancel
a.mu.Unlock()
a.runContainerLoop( a.runContainerLoop(
ctx, ctx,
cancel,
createResp.ID, createResp.ID,
params.ContainerConfig.Image, params.ContainerConfig.Image,
params.NetworkCountConfig, params.NetworkCountConfig,
params.Logs,
params.ShouldRestart,
cmp.Or(params.RestartInterval, defaultRestartInterval),
containerStateC, containerStateC,
errC, errC,
) )
@ -309,64 +362,39 @@ func (a *Client) pullImageIfNeeded(ctx context.Context, imageName string, contai
return nil return nil
} }
type containerWaitResponse struct {
container.WaitResponse
restarting bool
restartCount int
err error
}
// runContainerLoop is the control loop for a single container. It returns only // runContainerLoop is the control loop for a single container. It returns only
// when the container exits. // when the container exits.
func (a *Client) runContainerLoop( func (a *Client) runContainerLoop(
ctx context.Context, ctx context.Context,
cancel context.CancelFunc,
containerID string, containerID string,
imageName string, imageName string,
networkCountConfig NetworkCountConfig, networkCountConfig NetworkCountConfig,
logConfig LogConfig,
shouldRestartFunc ShouldRestartFunc,
restartInterval time.Duration,
stateC chan<- domain.Container, stateC chan<- domain.Container,
errC chan<- error, errC chan<- error,
) { ) {
type containerWaitResponse struct { defer cancel()
container.WaitResponse
restarting bool
}
containerRespC := make(chan containerWaitResponse) containerRespC := make(chan containerWaitResponse)
containerErrC := make(chan error) containerErrC := make(chan error, 1)
statsC := a.getStats(containerID, networkCountConfig) statsC := a.getStats(containerID, networkCountConfig)
eventsC := a.getEvents(containerID) eventsC := a.getEvents(containerID)
// ContainerWait only sends a result for the first non-running state, so we
// need to poll it repeatedly.
//
// The goroutine exits when a value is received on the error channel, or when
// the container exits and is not restarting, or when the context is cancelled.
go func() { go func() {
for { var restartCount int
respC, errC := a.apiClient.ContainerWait(ctx, containerID, container.WaitConditionNextExit) for a.waitForContainerExit(ctx, containerID, containerRespC, containerErrC, logConfig, shouldRestartFunc, restartInterval, restartCount) {
select { restartCount++
case resp := <-respC:
var restarting bool
// Check if the container is restarting. If it is not then we don't
// want to wait for it again and can return early.
ctr, err := a.apiClient.ContainerInspect(ctx, containerID)
// Race condition: the container may already have been removed.
if errdefs.IsNotFound(err) {
// ignore error but do not restart
} else if err != nil {
a.logger.Error("Error inspecting container", "err", err, "id", shortID(containerID))
containerErrC <- err
return
// Race condition: the container may have already restarted.
} else if ctr.State.Status == domain.ContainerStatusRestarting || ctr.State.Status == domain.ContainerStatusRunning {
restarting = true
}
containerRespC <- containerWaitResponse{WaitResponse: resp, restarting: restarting}
if !restarting {
return
}
case err := <-errC:
// Otherwise, this is probably unexpected and we need to handle it.
containerErrC <- err
return
case <-ctx.Done():
containerErrC <- ctx.Err()
return
}
} }
}() }()
@ -384,20 +412,23 @@ func (a *Client) runContainerLoop(
a.logger.Info("Container entered non-running state", "exit_code", resp.StatusCode, "id", shortID(containerID), "restarting", resp.restarting) a.logger.Info("Container entered non-running state", "exit_code", resp.StatusCode, "id", shortID(containerID), "restarting", resp.restarting)
var containerState string var containerState string
var containerErr error
if resp.restarting { if resp.restarting {
containerState = domain.ContainerStatusRestarting containerState = domain.ContainerStatusRestarting
} else { } else {
containerState = domain.ContainerStatusExited containerState = domain.ContainerStatusExited
containerErr = resp.err
} }
state.Status = containerState state.Status = containerState
state.Err = containerErr
state.RestartCount = resp.restartCount
state.CPUPercent = 0 state.CPUPercent = 0
state.MemoryUsageBytes = 0 state.MemoryUsageBytes = 0
state.HealthState = "unhealthy" state.HealthState = "unhealthy"
state.RxRate = 0 state.RxRate = 0
state.TxRate = 0 state.TxRate = 0
state.RxSince = time.Time{} state.RxSince = time.Time{}
state.RestartCount++
if !resp.restarting { if !resp.restarting {
exitCode := int(resp.StatusCode) exitCode := int(resp.StatusCode)
@ -408,7 +439,7 @@ func (a *Client) runContainerLoop(
sendState() sendState()
case err := <-containerErrC: case err := <-containerErrC:
// TODO: error handling? // TODO: verify error handling
if err != context.Canceled { if err != context.Canceled {
a.logger.Error("Error setting container wait", "err", err, "id", shortID(containerID)) a.logger.Error("Error setting container wait", "err", err, "id", shortID(containerID))
} }
@ -422,6 +453,7 @@ func (a *Client) runContainerLoop(
if evt.Action == "start" { if evt.Action == "start" {
state.Status = domain.ContainerStatusRunning state.Status = domain.ContainerStatusRunning
sendState() sendState()
continue continue
} }
@ -451,6 +483,96 @@ func (a *Client) runContainerLoop(
} }
} }
// waitForContainerExit blocks while it waits for a container to exit, and restarts
// it if configured to do so.
func (a *Client) waitForContainerExit(
ctx context.Context,
containerID string,
containerRespC chan<- containerWaitResponse,
containerErrC chan<- error,
logConfig LogConfig,
shouldRestartFunc ShouldRestartFunc,
restartInterval time.Duration,
restartCount int,
) bool {
var logs [][]byte
startedWaitingAt := time.Now()
respC, errC := a.apiClient.ContainerWait(ctx, containerID, container.WaitConditionNextExit)
logsC := a.getLogs(ctx, containerID, logConfig)
timer := time.NewTimer(restartInterval)
defer timer.Stop()
timer.Stop()
for {
select {
case resp := <-respC:
exit := func(err error) {
a.logger.Info("Container exited", "id", shortID(containerID), "should_restart", "false", "exit_code", resp.StatusCode, "restart_count", restartCount)
containerRespC <- containerWaitResponse{
WaitResponse: resp,
restarting: false,
restartCount: restartCount,
err: err,
}
}
// If the container exited with a non-zero status code, and debug
// logging is not enabled, log the container logs at ERROR level for
// debugging.
// TODO: parameterize
if resp.StatusCode != 0 && !a.logger.Enabled(ctx, slog.LevelDebug) {
for _, line := range logs {
a.logger.Error("Container log", "id", shortID(containerID), "log", string(line))
}
}
if shouldRestartFunc == nil {
exit(nil)
return false
}
shouldRestart, err := shouldRestartFunc(resp.StatusCode, restartCount, logs, time.Since(startedWaitingAt))
if shouldRestart && err != nil {
panic(fmt.Errorf("shouldRestart must return nil error if restarting, but returned: %w", err))
}
if !shouldRestart {
exit(err)
return false
}
a.logger.Info("Container exited", "id", shortID(containerID), "should_restart", "true", "exit_code", resp.StatusCode, "restart_count", restartCount)
timer.Reset(restartInterval)
containerRespC <- containerWaitResponse{
WaitResponse: resp,
restarting: true,
restartCount: restartCount,
}
// Don't return yet. Wait for the timer to fire.
case <-timer.C:
a.logger.Info("Container restarting", "id", shortID(containerID), "restart_count", restartCount)
if err := a.apiClient.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
containerErrC <- fmt.Errorf("container start: %w", err)
return false
}
a.logger.Info("Restarted container", "id", shortID(containerID))
return true
case line := <-logsC:
a.logger.Debug("Container log", "id", shortID(containerID), "log", string(line))
// TODO: limit max stored lines
logs = append(logs, line)
case err := <-errC:
containerErrC <- err
return false
case <-ctx.Done():
// This is probably because the container was stopped.
containerRespC <- containerWaitResponse{WaitResponse: container.WaitResponse{}, restarting: false}
return false
}
}
}
// Close closes the client, stopping and removing all running containers. // Close closes the client, stopping and removing all running containers.
func (a *Client) Close() error { func (a *Client) Close() error {
a.cancel() a.cancel()
@ -481,6 +603,24 @@ func (a *Client) Close() error {
} }
func (a *Client) removeContainer(ctx context.Context, id string) error { func (a *Client) removeContainer(ctx context.Context, id string) error {
a.mu.Lock()
cancel, ok := a.cancelFuncs[id]
if ok {
delete(a.cancelFuncs, id)
}
a.mu.Unlock()
if ok {
cancel()
} else {
// It is attempted to keep track of cancel functions for each container,
// which allow clean cancellation of container restart logic during
// removal. But there are legitimate occasions where the cancel function
// would not exist (e.g. during startup check) and in general the state of
// the Docker engine is preferred to local state in this package.
a.logger.Debug("removeContainer: cancelFunc not found", "id", shortID(id))
}
a.logger.Info("Stopping container", "id", shortID(id)) a.logger.Info("Stopping container", "id", shortID(id))
stopTimeout := int(stopTimeout.Seconds()) stopTimeout := int(stopTimeout.Seconds())
if err := a.apiClient.ContainerStop(ctx, id, container.StopOptions{Timeout: &stopTimeout}); err != nil { if err := a.apiClient.ContainerStop(ctx, id, container.StopOptions{Timeout: &stopTimeout}); err != nil {

View File

@ -44,7 +44,7 @@ func TestClientRunContainer(t *testing.T) {
dockerClient. dockerClient.
EXPECT(). EXPECT().
ImagePull(mock.Anything, "alpine", image.PullOptions{}). ImagePull(mock.Anything, "alpine", image.PullOptions{}).
Return(io.NopCloser(bytes.NewReader(nil)), nil) Return(nil, errors.New("error pulling image should not be fatal"))
dockerClient. dockerClient.
EXPECT(). EXPECT().
ContainerCreate(mock.Anything, mock.Anything, mock.Anything, mock.Anything, (*ocispec.Platform)(nil), mock.Anything). ContainerCreate(mock.Anything, mock.Anything, mock.Anything, mock.Anything, (*ocispec.Platform)(nil), mock.Anything).
@ -69,14 +69,14 @@ func TestClientRunContainer(t *testing.T) {
EXPECT(). EXPECT().
ContainerWait(mock.Anything, "123", dockercontainer.WaitConditionNextExit). ContainerWait(mock.Anything, "123", dockercontainer.WaitConditionNextExit).
Return(containerWaitC, containerErrC) Return(containerWaitC, containerErrC)
dockerClient.
EXPECT().
ContainerInspect(mock.Anything, "123").
Return(dockercontainer.InspectResponse{ContainerJSONBase: &dockercontainer.ContainerJSONBase{State: &dockercontainer.State{Status: "exited"}}}, nil)
dockerClient. dockerClient.
EXPECT(). EXPECT().
Events(mock.Anything, events.ListOptions{Filters: filters.NewArgs(filters.Arg("container", "123"), filters.Arg("type", "container"))}). Events(mock.Anything, events.ListOptions{Filters: filters.NewArgs(filters.Arg("container", "123"), filters.Arg("type", "container"))}).
Return(eventsC, eventsErrC) Return(eventsC, eventsErrC)
dockerClient.
EXPECT().
ContainerLogs(mock.Anything, "123", mock.Anything).
Return(io.NopCloser(bytes.NewReader(nil)), nil)
containerClient, err := container.NewClient(t.Context(), &dockerClient, logger) containerClient, err := container.NewClient(t.Context(), &dockerClient, logger)
require.NoError(t, err) require.NoError(t, err)
@ -86,7 +86,8 @@ func TestClientRunContainer(t *testing.T) {
ChanSize: 1, ChanSize: 1,
ContainerConfig: &dockercontainer.Config{Image: "alpine"}, ContainerConfig: &dockercontainer.Config{Image: "alpine"},
HostConfig: &dockercontainer.HostConfig{}, HostConfig: &dockercontainer.HostConfig{},
CopyFileConfigs: []container.CopyFileConfig{ Logs: container.LogConfig{Stdout: true},
CopyFiles: []container.CopyFileConfig{
{ {
Path: "/hello", Path: "/hello",
Payload: bytes.NewReader([]byte("world")), Payload: bytes.NewReader([]byte("world")),
@ -122,7 +123,125 @@ func TestClientRunContainer(t *testing.T) {
assert.Equal(t, "unhealthy", state.HealthState) assert.Equal(t, "unhealthy", state.HealthState)
require.NotNil(t, state.ExitCode) require.NotNil(t, state.ExitCode)
assert.Equal(t, 1, *state.ExitCode) assert.Equal(t, 1, *state.ExitCode)
assert.Equal(t, 0, state.RestartCount)
<-done
}
func TestClientRunContainerWithRestart(t *testing.T) {
logger := testhelpers.NewTestLogger(t)
// channels returned by Docker's ContainerWait:
containerWaitC := make(chan dockercontainer.WaitResponse)
containerErrC := make(chan error)
// channels returned by Docker's Events:
eventsC := make(chan events.Message)
eventsErrC := make(chan error)
var dockerClient mocks.DockerClient
defer dockerClient.AssertExpectations(t)
dockerClient.
EXPECT().
NetworkCreate(mock.Anything, mock.Anything, mock.MatchedBy(func(opts network.CreateOptions) bool {
return opts.Driver == "bridge" && len(opts.Labels) > 0
})).
Return(network.CreateResponse{ID: "test-network"}, nil)
dockerClient.
EXPECT().
ImagePull(mock.Anything, "alpine", image.PullOptions{}).
Return(io.NopCloser(bytes.NewReader(nil)), nil)
dockerClient.
EXPECT().
ContainerCreate(mock.Anything, mock.Anything, mock.Anything, mock.Anything, (*ocispec.Platform)(nil), mock.Anything).
Return(dockercontainer.CreateResponse{ID: "123"}, nil)
dockerClient.
EXPECT().
NetworkConnect(mock.Anything, "test-network", "123", (*network.EndpointSettings)(nil)).
Return(nil)
dockerClient.
EXPECT().
ContainerStart(mock.Anything, "123", dockercontainer.StartOptions{}).
Once().
Return(nil)
dockerClient.
EXPECT().
ContainerStats(mock.Anything, "123", true).
Return(dockercontainer.StatsResponseReader{Body: io.NopCloser(bytes.NewReader(nil))}, nil)
dockerClient.
EXPECT().
ContainerWait(mock.Anything, "123", dockercontainer.WaitConditionNextExit).
Return(containerWaitC, containerErrC)
dockerClient.
EXPECT().
Events(mock.Anything, events.ListOptions{Filters: filters.NewArgs(filters.Arg("container", "123"), filters.Arg("type", "container"))}).
Return(eventsC, eventsErrC)
dockerClient.
EXPECT().
ContainerStart(mock.Anything, "123", dockercontainer.StartOptions{}). // restart
Return(nil)
dockerClient.
EXPECT().
ContainerLogs(mock.Anything, "123", mock.Anything).
Return(io.NopCloser(bytes.NewReader(nil)), nil)
containerClient, err := container.NewClient(t.Context(), &dockerClient, logger)
require.NoError(t, err)
containerStateC, errC := containerClient.RunContainer(t.Context(), container.RunContainerParams{
Name: "test-run-container",
ChanSize: 1,
ContainerConfig: &dockercontainer.Config{Image: "alpine"},
HostConfig: &dockercontainer.HostConfig{},
Logs: container.LogConfig{Stdout: true},
ShouldRestart: func(_ int64, restartCount int, _ [][]byte, _ time.Duration) (bool, error) {
if restartCount == 0 {
return true, nil
}
return false, errors.New("max restarts reached")
},
RestartInterval: 10 * time.Millisecond,
})
done := make(chan struct{})
go func() {
defer close(done)
require.NoError(t, <-errC)
}()
assert.Equal(t, "pulling", (<-containerStateC).Status)
assert.Equal(t, "created", (<-containerStateC).Status)
assert.Equal(t, "running", (<-containerStateC).Status)
assert.Equal(t, "running", (<-containerStateC).Status)
// Enough time for the restart to occur:
time.Sleep(100 * time.Millisecond)
containerWaitC <- dockercontainer.WaitResponse{StatusCode: 1}
state := <-containerStateC
assert.Equal(t, "restarting", state.Status)
assert.Equal(t, "unhealthy", state.HealthState)
assert.Nil(t, state.ExitCode)
assert.Zero(t, state.RestartCount) // not incremented until the actual restart
// During the restart, the "running" status is triggered by Docker events
// only. So we don't expect one in unit tests. (Probably the initial startup
// flow should behave the same.)
time.Sleep(100 * time.Millisecond)
containerWaitC <- dockercontainer.WaitResponse{StatusCode: 1}
state = <-containerStateC
assert.Equal(t, "exited", state.Status)
assert.Equal(t, "unhealthy", state.HealthState)
require.NotNil(t, state.ExitCode)
assert.Equal(t, 1, *state.ExitCode)
assert.Equal(t, 1, state.RestartCount) assert.Equal(t, 1, state.RestartCount)
assert.Equal(t, "max restarts reached", state.Err.Error())
<-done <-done
} }

View File

@ -23,7 +23,7 @@ func TestIntegrationClientStartStop(t *testing.T) {
t.Cleanup(cancel) t.Cleanup(cancel)
logger := testhelpers.NewTestLogger(t) logger := testhelpers.NewTestLogger(t)
apiClient, err := client.NewClientWithOpts(client.FromEnv) apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
require.NoError(t, err) require.NoError(t, err)
containerName := "octoplex-test-" + shortid.New().String() containerName := "octoplex-test-" + shortid.New().String()
component := "test-start-stop" component := "test-start-stop"
@ -39,7 +39,7 @@ func TestIntegrationClientStartStop(t *testing.T) {
Name: containerName, Name: containerName,
ChanSize: 1, ChanSize: 1,
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: "netfluxio/mediamtx-alpine:latest", Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
Labels: map[string]string{container.LabelComponent: component}, Labels: map[string]string{container.LabelComponent: component},
}, },
HostConfig: &typescontainer.HostConfig{ HostConfig: &typescontainer.HostConfig{
@ -73,7 +73,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
t.Cleanup(cancel) t.Cleanup(cancel)
logger := testhelpers.NewTestLogger(t) logger := testhelpers.NewTestLogger(t)
apiClient, err := client.NewClientWithOpts(client.FromEnv) apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
require.NoError(t, err) require.NoError(t, err)
component := "test-remove-containers" component := "test-remove-containers"
@ -84,7 +84,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
stateC, err1C := client.RunContainer(ctx, container.RunContainerParams{ stateC, err1C := client.RunContainer(ctx, container.RunContainerParams{
ChanSize: 1, ChanSize: 1,
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: "netfluxio/mediamtx-alpine:latest", Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
Labels: map[string]string{container.LabelComponent: component, "group": "test1"}, Labels: map[string]string{container.LabelComponent: component, "group": "test1"},
}, },
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"}, HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
@ -95,7 +95,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
stateC, err2C := client.RunContainer(ctx, container.RunContainerParams{ stateC, err2C := client.RunContainer(ctx, container.RunContainerParams{
ChanSize: 1, ChanSize: 1,
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: "netfluxio/mediamtx-alpine:latest", Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
Labels: map[string]string{container.LabelComponent: component, "group": "test1"}, Labels: map[string]string{container.LabelComponent: component, "group": "test1"},
}, },
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"}, HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
@ -106,7 +106,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
stateC, err3C := client.RunContainer(ctx, container.RunContainerParams{ stateC, err3C := client.RunContainer(ctx, container.RunContainerParams{
ChanSize: 1, ChanSize: 1,
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: "netfluxio/mediamtx-alpine:latest", Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
Labels: map[string]string{container.LabelComponent: component, "group": "test2"}, Labels: map[string]string{container.LabelComponent: component, "group": "test2"},
}, },
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"}, HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
@ -172,7 +172,7 @@ func TestContainerRestart(t *testing.T) {
t.Cleanup(cancel) t.Cleanup(cancel)
logger := testhelpers.NewTestLogger(t) logger := testhelpers.NewTestLogger(t)
apiClient, err := client.NewClientWithOpts(client.FromEnv) apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
require.NoError(t, err) require.NoError(t, err)
containerName := "octoplex-test-" + shortid.New().String() containerName := "octoplex-test-" + shortid.New().String()
component := "test-restart" component := "test-restart"

View File

@ -0,0 +1,53 @@
package container
import (
"bufio"
"context"
"log/slog"
typescontainer "github.com/docker/docker/api/types/container"
)
func getLogs(
ctx context.Context,
containerID string,
apiClient DockerClient,
cfg LogConfig,
ch chan<- []byte,
logger *slog.Logger,
) {
logsC, err := apiClient.ContainerLogs(
ctx,
containerID,
typescontainer.LogsOptions{
ShowStdout: cfg.Stdout,
ShowStderr: cfg.Stderr,
Follow: true,
},
)
if err != nil {
logger.Error("Error getting container logs", "err", err, "id", shortID(containerID))
return
}
defer logsC.Close()
scanner := bufio.NewScanner(logsC)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
// Docker logs are prefixed with an 8 byte prefix.
// See client.ContainerLogs for more details.
// We could use
// [StdCopy](https://pkg.go.dev/github.com/docker/docker/pkg/stdcopy#StdCopy)
// but for our purposes it's enough to just slice it off.
const prefixLen = 8
line := scanner.Bytes()
if len(line) <= prefixLen {
continue
}
ch <- line[prefixLen:]
}
}
}

View File

@ -0,0 +1,45 @@
package container
import (
"io"
"strings"
"testing"
"git.netflux.io/rob/octoplex/internal/container/mocks"
"git.netflux.io/rob/octoplex/internal/testhelpers"
typescontainer "github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestGetLogs(t *testing.T) {
var dockerClient mocks.DockerClient
dockerClient.
EXPECT().
ContainerLogs(mock.Anything, "123", typescontainer.LogsOptions{ShowStderr: true, Follow: true}).
Return(io.NopCloser(strings.NewReader("********line 1\n********line 2\n********line 3\n")), nil)
ch := make(chan []byte)
go func() {
defer close(ch)
getLogs(
t.Context(),
"123",
&dockerClient,
LogConfig{Stderr: true},
ch,
testhelpers.NewTestLogger(t),
)
}()
// Ensure we get the expected lines, including stripping 8 bytes of Docker
// multiplexing prefix.
assert.Equal(t, "line 1", string(<-ch))
assert.Equal(t, "line 2", string(<-ch))
assert.Equal(t, "line 3", string(<-ch))
_, ok := <-ch
assert.False(t, ok)
}

View File

@ -138,63 +138,6 @@ func (_c *DockerClient_ContainerCreate_Call) RunAndReturn(run func(context.Conte
return _c return _c
} }
// ContainerInspect provides a mock function with given fields: _a0, _a1
func (_m *DockerClient) ContainerInspect(_a0 context.Context, _a1 string) (typescontainer.InspectResponse, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for ContainerInspect")
}
var r0 typescontainer.InspectResponse
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (typescontainer.InspectResponse, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, string) typescontainer.InspectResponse); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(typescontainer.InspectResponse)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DockerClient_ContainerInspect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContainerInspect'
type DockerClient_ContainerInspect_Call struct {
*mock.Call
}
// ContainerInspect is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 string
func (_e *DockerClient_Expecter) ContainerInspect(_a0 interface{}, _a1 interface{}) *DockerClient_ContainerInspect_Call {
return &DockerClient_ContainerInspect_Call{Call: _e.mock.On("ContainerInspect", _a0, _a1)}
}
func (_c *DockerClient_ContainerInspect_Call) Run(run func(_a0 context.Context, _a1 string)) *DockerClient_ContainerInspect_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *DockerClient_ContainerInspect_Call) Return(_a0 typescontainer.InspectResponse, _a1 error) *DockerClient_ContainerInspect_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *DockerClient_ContainerInspect_Call) RunAndReturn(run func(context.Context, string) (typescontainer.InspectResponse, error)) *DockerClient_ContainerInspect_Call {
_c.Call.Return(run)
return _c
}
// ContainerList provides a mock function with given fields: _a0, _a1 // ContainerList provides a mock function with given fields: _a0, _a1
func (_m *DockerClient) ContainerList(_a0 context.Context, _a1 typescontainer.ListOptions) ([]typescontainer.Summary, error) { func (_m *DockerClient) ContainerList(_a0 context.Context, _a1 typescontainer.ListOptions) ([]typescontainer.Summary, error) {
ret := _m.Called(_a0, _a1) ret := _m.Called(_a0, _a1)
@ -254,6 +197,66 @@ func (_c *DockerClient_ContainerList_Call) RunAndReturn(run func(context.Context
return _c return _c
} }
// ContainerLogs provides a mock function with given fields: _a0, _a1, _a2
func (_m *DockerClient) ContainerLogs(_a0 context.Context, _a1 string, _a2 typescontainer.LogsOptions) (io.ReadCloser, error) {
ret := _m.Called(_a0, _a1, _a2)
if len(ret) == 0 {
panic("no return value specified for ContainerLogs")
}
var r0 io.ReadCloser
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, typescontainer.LogsOptions) (io.ReadCloser, error)); ok {
return rf(_a0, _a1, _a2)
}
if rf, ok := ret.Get(0).(func(context.Context, string, typescontainer.LogsOptions) io.ReadCloser); ok {
r0 = rf(_a0, _a1, _a2)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(io.ReadCloser)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, typescontainer.LogsOptions) error); ok {
r1 = rf(_a0, _a1, _a2)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DockerClient_ContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContainerLogs'
type DockerClient_ContainerLogs_Call struct {
*mock.Call
}
// ContainerLogs is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 string
// - _a2 typescontainer.LogsOptions
func (_e *DockerClient_Expecter) ContainerLogs(_a0 interface{}, _a1 interface{}, _a2 interface{}) *DockerClient_ContainerLogs_Call {
return &DockerClient_ContainerLogs_Call{Call: _e.mock.On("ContainerLogs", _a0, _a1, _a2)}
}
func (_c *DockerClient_ContainerLogs_Call) Run(run func(_a0 context.Context, _a1 string, _a2 typescontainer.LogsOptions)) *DockerClient_ContainerLogs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(typescontainer.LogsOptions))
})
return _c
}
func (_c *DockerClient_ContainerLogs_Call) Return(_a0 io.ReadCloser, _a1 error) *DockerClient_ContainerLogs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *DockerClient_ContainerLogs_Call) RunAndReturn(run func(context.Context, string, typescontainer.LogsOptions) (io.ReadCloser, error)) *DockerClient_ContainerLogs_Call {
_c.Call.Return(run)
return _c
}
// ContainerRemove provides a mock function with given fields: _a0, _a1, _a2 // ContainerRemove provides a mock function with given fields: _a0, _a1, _a2
func (_m *DockerClient) ContainerRemove(_a0 context.Context, _a1 string, _a2 typescontainer.RemoveOptions) error { func (_m *DockerClient) ContainerRemove(_a0 context.Context, _a1 string, _a2 typescontainer.RemoveOptions) error {
ret := _m.Called(_a0, _a1, _a2) ret := _m.Called(_a0, _a1, _a2)

View File

@ -1,4 +1,4 @@
package terminal package domain
// CommandAddDestination adds a destination. // CommandAddDestination adds a destination.
type CommandAddDestination struct { type CommandAddDestination struct {

View File

@ -31,14 +31,11 @@ type BuildInfo struct {
// Source represents the source, currently always the mediaserver. // Source represents the source, currently always the mediaserver.
type Source struct { type Source struct {
Container Container Container Container
Live bool Live bool
LiveChangedAt time.Time LiveChangedAt time.Time
Listeners int Tracks []string
Tracks []string ExitReason string
RTMPURL string
RTMPInternalURL string
ExitReason string
} }
// DestinationStatus reflects the high-level status of a single destination. // DestinationStatus reflects the high-level status of a single destination.
@ -58,6 +55,27 @@ type Destination struct {
URL string URL string
} }
// NetAddr holds a network address.
type NetAddr struct {
IP string
Port int
}
// IsZero returns true if the NetAddr is zero value.
func (n NetAddr) IsZero() bool {
return n.IP == "" && n.Port == 0
}
// KeyPair holds a TLS key pair.
type KeyPair struct {
Cert, Key []byte
}
// IsZero returns true if the KeyPair is zero value.
func (k KeyPair) IsZero() bool {
return k.Cert == nil && k.Key == nil
}
// Container status strings. // Container status strings.
// //
// TODO: refactor to strictly reflect Docker status strings. // TODO: refactor to strictly reflect Docker status strings.

View File

@ -31,3 +31,21 @@ func TestAppStateClone(t *testing.T) {
s.Destinations[0].Name = "Twitch" s.Destinations[0].Name = "Twitch"
assert.Equal(t, "YouTube", s2.Destinations[0].Name) assert.Equal(t, "YouTube", s2.Destinations[0].Name)
} }
func TestNetAddr(t *testing.T) {
var addr domain.NetAddr
assert.True(t, addr.IsZero())
addr.IP = "127.0.0.1"
addr.Port = 3000
assert.False(t, addr.IsZero())
}
func TestKeyPair(t *testing.T) {
var keyPair domain.KeyPair
assert.True(t, keyPair.IsZero())
keyPair.Cert = []byte("cert")
keyPair.Key = []byte("key")
assert.False(t, keyPair.IsZero())
}

View File

@ -8,7 +8,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "os"
"time" "time"
typescontainer "github.com/docker/docker/api/types/container" typescontainer "github.com/docker/docker/api/types/container"
@ -27,14 +27,22 @@ import (
type StreamKey string type StreamKey string
const ( const (
defaultFetchIngressStateInterval = 5 * time.Second // default interval to fetch the state of the media server defaultUpdateStateInterval = 5 * time.Second // default interval to update the state of the media server
defaultAPIPort = 9997 // default API host port for the media server defaultAPIPort = 9997 // default API host port for the media server
defaultRTMPPort = 1935 // default RTMP host port for the media server defaultRTMPIP = "127.0.0.1" // default RTMP host IP, bound to localhost for security
defaultChanSize = 64 // default channel size for asynchronous non-error channels defaultRTMPPort = 1935 // default RTMP host port for the media server
imageNameMediaMTX = "netfluxio/mediamtx-alpine:latest" // image name for mediamtx defaultRTMPSPort = 1936 // default RTMPS host port for the media server
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey]. defaultHost = "localhost" // default mediaserver host name
componentName = "mediaserver" // component name, mostly used for Docker labels defaultChanSize = 64 // default channel size for asynchronous non-error channels
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests imageNameMediaMTX = "ghcr.io/rfwatson/mediamtx-alpine:latest" // image name for mediamtx
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey].
componentName = "mediaserver" // component name, mostly used for Docker labels
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests
configPath = "/mediamtx.yml" // path to the media server config file
tlsInternalCertPath = "/etc/tls-internal.crt" // path to the internal TLS cert
tlsInternalKeyPath = "/etc/tls-internal.key" // path to the internal TLS key
tlsCertPath = "/etc/tls.crt" // path to the custom TLS cert
tlsKeyPath = "/etc/tls.key" // path to the custom TLS key
) )
// action is an action to be performed by the actor. // action is an action to be performed by the actor.
@ -42,123 +50,179 @@ type action func()
// Actor is responsible for managing the media server. // Actor is responsible for managing the media server.
type Actor struct { type Actor struct {
ctx context.Context actorC chan action
cancel context.CancelFunc stateC chan domain.Source
actorC chan action chanSize int
stateC chan domain.Source containerClient *container.Client
containerClient *container.Client rtmpAddr domain.NetAddr
apiPort int rtmpsAddr domain.NetAddr
rtmpPort int apiPort int
streamKey StreamKey host string
fetchIngressStateInterval time.Duration streamKey StreamKey
pass string // password for the media server updateStateInterval time.Duration
logger *slog.Logger pass string // password for the media server
apiClient *http.Client keyPairInternal domain.KeyPair // TLS key pair for the media server
keyPairCustom domain.KeyPair // TLS key pair for the media server
logger *slog.Logger
apiClient *http.Client
// mutable state // mutable state
state *domain.Source state *domain.Source
} }
// StartActorParams contains the parameters for starting a new media server // NewActorParams contains the parameters for building a new media server
// actor. // actor.
type StartActorParams struct { type NewActorParams struct {
APIPort int // defaults to 9997 RTMPAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1935
RTMPPort int // defaults to 1935 RTMPSAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1936
StreamKey StreamKey // defaults to "live" APIPort int // defaults to 9997
ChanSize int // defaults to 64 Host string // defaults to "localhost"
FetchIngressStateInterval time.Duration // defaults to 5 seconds TLSCertPath string // defaults to empty
ContainerClient *container.Client TLSKeyPath string // defaults to empty
Logger *slog.Logger StreamKey StreamKey // defaults to "live"
ChanSize int // defaults to 64
UpdateStateInterval time.Duration // defaults to 5 seconds
ContainerClient *container.Client
Logger *slog.Logger
} }
// StartActor starts a new media server actor. // OptionalNetAddr is a wrapper around domain.NetAddr that indicates whether it
// is enabled or not.
type OptionalNetAddr struct {
domain.NetAddr
Enabled bool
}
// NewActor creates a new media server actor.
// //
// Callers must consume the state channel exposed via [C]. // Callers must consume the state channel exposed via [C].
func StartActor(ctx context.Context, params StartActorParams) (_ *Actor, err error) { func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error) {
ctx, cancel := context.WithCancel(ctx) dnsNames := []string{"localhost"}
defer func() { if params.Host != "" {
// if err is nil, the context should not be cancelled. dnsNames = append(dnsNames, params.Host)
if err != nil { }
cancel()
}
}()
tlsCert, tlsKey, err := generateTLSCert() keyPairInternal, err := generateTLSCert(dnsNames...)
if err != nil { if err != nil {
return nil, fmt.Errorf("generate TLS cert: %w", err) return nil, fmt.Errorf("generate TLS cert: %w", err)
} }
apiClient, err := buildAPIClient(tlsCert)
var keyPairCustom domain.KeyPair
if params.TLSCertPath != "" {
keyPairCustom.Cert, err = os.ReadFile(params.TLSCertPath)
if err != nil {
return nil, fmt.Errorf("read TLS cert: %w", err)
}
keyPairCustom.Key, err = os.ReadFile(params.TLSKeyPath)
if err != nil {
return nil, fmt.Errorf("read TLS key: %w", err)
}
}
// TODO: custom cert for API?
apiClient, err := buildAPIClient(keyPairInternal.Cert)
if err != nil { if err != nil {
return nil, fmt.Errorf("build API client: %w", err) return nil, fmt.Errorf("build API client: %w", err)
} }
chanSize := cmp.Or(params.ChanSize, defaultChanSize) chanSize := cmp.Or(params.ChanSize, defaultChanSize)
actor := &Actor{ return &Actor{
ctx: ctx, rtmpAddr: toRTMPAddr(params.RTMPAddr, defaultRTMPPort),
cancel: cancel, rtmpsAddr: toRTMPAddr(params.RTMPSAddr, defaultRTMPSPort),
apiPort: cmp.Or(params.APIPort, defaultAPIPort), apiPort: cmp.Or(params.APIPort, defaultAPIPort),
rtmpPort: cmp.Or(params.RTMPPort, defaultRTMPPort), host: cmp.Or(params.Host, defaultHost),
streamKey: cmp.Or(params.StreamKey, defaultStreamKey), streamKey: cmp.Or(params.StreamKey, defaultStreamKey),
fetchIngressStateInterval: cmp.Or(params.FetchIngressStateInterval, defaultFetchIngressStateInterval), updateStateInterval: cmp.Or(params.UpdateStateInterval, defaultUpdateStateInterval),
pass: generatePassword(), keyPairInternal: keyPairInternal,
actorC: make(chan action, chanSize), keyPairCustom: keyPairCustom,
state: new(domain.Source), pass: generatePassword(),
stateC: make(chan domain.Source, chanSize), actorC: make(chan action, chanSize),
containerClient: params.ContainerClient, state: new(domain.Source),
logger: params.Logger, stateC: make(chan domain.Source, chanSize),
apiClient: apiClient, chanSize: chanSize,
containerClient: params.ContainerClient,
logger: params.Logger,
apiClient: apiClient,
}, nil
}
func (a *Actor) Start(ctx context.Context) error {
var portSpecs []string
portSpecs = append(portSpecs, fmt.Sprintf("127.0.0.1:%d:9997", a.apiPort))
if !a.rtmpAddr.IsZero() {
portSpecs = append(portSpecs, fmt.Sprintf("%s:%d:%d", a.rtmpAddr.IP, a.rtmpAddr.Port, 1935))
}
if !a.rtmpsAddr.IsZero() {
portSpecs = append(portSpecs, fmt.Sprintf("%s:%d:%d", a.rtmpsAddr.IP, a.rtmpsAddr.Port, 1936))
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(portSpecs)
if err != nil {
return fmt.Errorf("parse port specs: %w", err)
} }
apiPortSpec := nat.Port(strconv.Itoa(actor.apiPort) + ":9997") cfg, err := a.buildServerConfig()
rtmpPortSpec := nat.Port(strconv.Itoa(actor.rtmpPort) + ":1935") if err != nil {
exposedPorts, portBindings, _ := nat.ParsePortSpecs([]string{string(apiPortSpec), string(rtmpPortSpec)}) return fmt.Errorf("build server config: %w", err)
}
cfg, err := yaml.Marshal( copyFiles := []container.CopyFileConfig{
Config{ {
LogLevel: "info", Path: configPath,
LogDestinations: []string{"stdout"}, Payload: bytes.NewReader(cfg),
AuthMethod: "internal", Mode: 0600,
AuthInternalUsers: []User{ },
{ {
User: "any", Path: tlsInternalCertPath,
IPs: []string{}, // any IP Payload: bytes.NewReader(a.keyPairInternal.Cert),
Permissions: []UserPermission{ Mode: 0600,
{Action: "publish"}, },
}, {
}, Path: tlsInternalKeyPath,
{ Payload: bytes.NewReader(a.keyPairInternal.Key),
User: "api", Mode: 0600,
Pass: actor.pass, },
IPs: []string{}, // any IP {
Permissions: []UserPermission{ Path: "/etc/healthcheckopts.txt",
{Action: "read"}, Payload: bytes.NewReader([]byte(fmt.Sprintf("--user api:%s", a.pass))),
}, Mode: 0600,
},
{
User: "api",
Pass: actor.pass,
IPs: []string{}, // any IP
Permissions: []UserPermission{{Action: "api"}},
},
},
API: true,
APIEncryption: true,
APIServerCert: "/etc/tls.crt",
APIServerKey: "/etc/tls.key",
Paths: map[string]Path{
string(actor.streamKey): {Source: "publisher"},
},
}, },
)
if err != nil { // should never happen
return nil, fmt.Errorf("marshal config: %w", err)
} }
containerStateC, errC := params.ContainerClient.RunContainer( if !a.keyPairCustom.IsZero() {
copyFiles = append(
copyFiles,
container.CopyFileConfig{
Path: tlsCertPath,
Payload: bytes.NewReader(a.keyPairCustom.Cert),
Mode: 0600,
},
container.CopyFileConfig{
Path: tlsKeyPath,
Payload: bytes.NewReader(a.keyPairCustom.Key),
Mode: 0600,
},
)
}
args := []any{"host", a.host}
if a.rtmpAddr.IsZero() {
args = append(args, "rtmp.enabled", false)
} else {
args = append(args, "rtmp.enabled", true, "rtmp.bind_addr", a.rtmpAddr.IP, "rtmp.bind_port", a.rtmpAddr.Port)
}
if a.rtmpsAddr.IsZero() {
args = append(args, "rtmps.enabled", false)
} else {
args = append(args, "rtmps.enabled", true, "rtmps.bind_addr", a.rtmpsAddr.IP, "rtmps.bind_port", a.rtmpsAddr.Port)
}
a.logger.Info("Starting media server", args...)
containerStateC, errC := a.containerClient.RunContainer(
ctx, ctx,
container.RunContainerParams{ container.RunContainerParams{
Name: componentName, Name: componentName,
ChanSize: chanSize, ChanSize: a.chanSize,
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: imageNameMediaMTX, Image: imageNameMediaMTX,
Hostname: "mediaserver", Hostname: "mediaserver",
@ -169,8 +233,9 @@ func StartActor(ctx context.Context, params StartActorParams) (_ *Actor, err err
"curl", "curl",
"--fail", "--fail",
"--silent", "--silent",
"--cacert", "/etc/tls.crt", "--cacert", "/etc/tls-internal.crt",
actor.pathsURL(), "--config", "/etc/healthcheckopts.txt",
a.healthCheckURL(),
}, },
Interval: time.Second * 10, Interval: time.Second * 10,
StartPeriod: time.Second * 2, StartPeriod: time.Second * 2,
@ -184,32 +249,79 @@ func StartActor(ctx context.Context, params StartActorParams) (_ *Actor, err err
PortBindings: portBindings, PortBindings: portBindings,
}, },
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"}, NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"},
CopyFileConfigs: []container.CopyFileConfig{ Logs: container.LogConfig{Stdout: true},
{ CopyFiles: copyFiles,
Path: "/mediamtx.yml",
Payload: bytes.NewReader(cfg),
Mode: 0600,
},
{
Path: "/etc/tls.crt",
Payload: bytes.NewReader(tlsCert),
Mode: 0600,
},
{
Path: "/etc/tls.key",
Payload: bytes.NewReader(tlsKey),
Mode: 0600,
},
},
}, },
) )
actor.state.RTMPURL = actor.rtmpURL() go a.actorLoop(ctx, containerStateC, errC)
actor.state.RTMPInternalURL = actor.rtmpInternalURL()
go actor.actorLoop(containerStateC, errC) return nil
}
return actor, nil func (a *Actor) buildServerConfig() ([]byte, error) {
// NOTE: Regardless of the user configuration (which mostly affects exposed
// ports and UI rendering) plain RTMP must be enabled at the container level,
// for internal connections.
var encryptionString string
if a.rtmpsAddr.IsZero() {
encryptionString = "no"
} else {
encryptionString = "optional"
}
var certPath, keyPath string
if a.keyPairCustom.IsZero() {
certPath = tlsInternalCertPath
keyPath = tlsInternalKeyPath
} else {
certPath = tlsCertPath
keyPath = tlsKeyPath
}
return yaml.Marshal(
Config{
LogLevel: "debug",
LogDestinations: []string{"stdout"},
AuthMethod: "internal",
AuthInternalUsers: []User{
{
User: "any",
IPs: []string{}, // any IP
Permissions: []UserPermission{
{Action: "publish"},
},
},
{
User: "api",
Pass: a.pass,
IPs: []string{}, // any IP
Permissions: []UserPermission{
{Action: "read"},
},
},
{
User: "api",
Pass: a.pass,
IPs: []string{}, // any IP
Permissions: []UserPermission{{Action: "api"}},
},
},
RTMP: true,
RTMPEncryption: encryptionString,
RTMPAddress: ":1935",
RTMPSAddress: ":1936",
RTMPServerCert: certPath,
RTMPServerKey: keyPath,
API: true,
APIEncryption: true,
APIServerCert: tlsInternalCertPath,
APIServerKey: tlsInternalKeyPath,
Paths: map[string]Path{
string(a.streamKey): {Source: "publisher"},
},
},
)
} }
// C returns a channel that will receive the current state of the media server. // C returns a channel that will receive the current state of the media server.
@ -218,6 +330,8 @@ func (s *Actor) C() <-chan domain.Source {
} }
// State returns the current state of the media server. // State returns the current state of the media server.
//
// Blocks if the actor is not started yet.
func (s *Actor) State() domain.Source { func (s *Actor) State() domain.Source {
resultChan := make(chan domain.Source) resultChan := make(chan domain.Source)
s.actorC <- func() { s.actorC <- func() {
@ -235,24 +349,14 @@ func (s *Actor) Close() error {
return fmt.Errorf("remove containers: %w", err) return fmt.Errorf("remove containers: %w", err)
} }
s.cancel()
return nil return nil
} }
// actorLoop is the main loop of the media server actor. It exits when the // actorLoop is the main loop of the media server actor. It exits when the
// actor is closed, or the parent context is cancelled. // actor is closed, or the parent context is cancelled.
func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan error) { func (s *Actor) actorLoop(ctx context.Context, containerStateC <-chan domain.Container, errC <-chan error) {
fetchStateT := time.NewTicker(s.fetchIngressStateInterval) updateStateT := time.NewTicker(s.updateStateInterval)
defer fetchStateT.Stop() defer updateStateT.Stop()
// fetchTracksT is used to signal that tracks should be fetched from the
// media server, after the stream goes on-air. A short delay is needed due to
// workaround a race condition in the media server.
var fetchTracksT *time.Timer
resetFetchTracksT := func(d time.Duration) { fetchTracksT = time.NewTimer(d) }
resetFetchTracksT(time.Second)
fetchTracksT.Stop()
sendState := func() { s.stateC <- *s.state } sendState := func() { s.stateC <- *s.state }
@ -262,7 +366,7 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
s.state.Container = containerState s.state.Container = containerState
if s.state.Container.Status == domain.ContainerStatusExited { if s.state.Container.Status == domain.ContainerStatusExited {
fetchStateT.Stop() updateStateT.Stop()
s.handleContainerExit(nil) s.handleContainerExit(nil)
} }
@ -281,43 +385,21 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
s.logger.Error("Error from container client", "err", err, "id", shortID(s.state.Container.ID)) s.logger.Error("Error from container client", "err", err, "id", shortID(s.state.Container.ID))
} }
fetchStateT.Stop() updateStateT.Stop()
s.handleContainerExit(err) s.handleContainerExit(err)
sendState() sendState()
case <-fetchStateT.C: case <-updateStateT.C:
ingressState, err := fetchIngressState(s.rtmpConnsURL(), s.streamKey, s.apiClient) path, err := fetchPath(s.pathURL(string(s.streamKey)), s.apiClient)
if err != nil { if err != nil {
s.logger.Error("Error fetching server state", "err", err) s.logger.Error("Error fetching path", "err", err)
continue continue
} }
var shouldSendState bool if path.Ready != s.state.Live {
if ingressState.ready != s.state.Live { s.state.Live = path.Ready
s.state.Live = ingressState.ready
s.state.LiveChangedAt = time.Now() s.state.LiveChangedAt = time.Now()
resetFetchTracksT(time.Second) s.state.Tracks = path.Tracks
shouldSendState = true
}
if ingressState.listeners != s.state.Listeners {
s.state.Listeners = ingressState.listeners
shouldSendState = true
}
if shouldSendState {
sendState()
}
case <-fetchTracksT.C:
if !s.state.Live {
continue
}
if tracks, err := fetchTracks(s.pathsURL(), s.streamKey, s.apiClient); err != nil {
s.logger.Error("Error fetching tracks", "err", err)
resetFetchTracksT(3 * time.Second)
} else if len(tracks) == 0 {
resetFetchTracksT(time.Second)
} else {
s.state.Tracks = tracks
sendState() sendState()
} }
case action, ok := <-s.actorC: case action, ok := <-s.actorC:
@ -325,7 +407,7 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
continue continue
} }
action() action()
case <-s.ctx.Done(): case <-ctx.Done():
return return
} }
} }
@ -345,27 +427,41 @@ func (s *Actor) handleContainerExit(err error) {
s.state.Live = false s.state.Live = false
} }
// rtmpURL returns the RTMP URL for the media server, accessible from the host. // RTMPURL returns the RTMP URL for the media server, accessible from the host.
func (s *Actor) rtmpURL() string { func (s *Actor) RTMPURL() string {
return fmt.Sprintf("rtmp://localhost:%d/%s", s.rtmpPort, s.streamKey) if s.rtmpAddr.IsZero() {
return ""
}
return fmt.Sprintf("rtmp://%s:%d/%s", s.host, s.rtmpAddr.Port, s.streamKey)
} }
// rtmpInternalURL returns the RTMP URL for the media server, accessible from // RTMPSURL returns the RTMPS URL for the media server, accessible from the host.
func (s *Actor) RTMPSURL() string {
if s.rtmpsAddr.IsZero() {
return ""
}
return fmt.Sprintf("rtmps://%s:%d/%s", s.host, s.rtmpsAddr.Port, s.streamKey)
}
// RTMPInternalURL returns the RTMP URL for the media server, accessible from
// the app network. // the app network.
func (s *Actor) rtmpInternalURL() string { func (s *Actor) RTMPInternalURL() string {
// Container port, not host port: // Container port, not host port:
return fmt.Sprintf("rtmp://mediaserver:1935/%s?user=api&pass=%s", s.streamKey, s.pass) return fmt.Sprintf("rtmp://mediaserver:1935/%s?user=api&pass=%s", s.streamKey, s.pass)
} }
// rtmpConnsURL returns the URL for fetching RTMP connections, accessible from // pathURL returns the URL for fetching a path, accessible from the host.
// the host. func (s *Actor) pathURL(path string) string {
func (s *Actor) rtmpConnsURL() string { return fmt.Sprintf("https://api:%s@localhost:%d/v3/paths/get/%s", s.pass, s.apiPort, path)
return fmt.Sprintf("https://api:%s@localhost:%d/v3/rtmpconns/list", s.pass, s.apiPort)
} }
// pathsURL returns the URL for fetching paths, accessible from the host. // healthCheckURL returns the URL for the health check, accessible from the
func (s *Actor) pathsURL() string { // container. It is logged to Docker's events log so must not include
return fmt.Sprintf("https://api:%s@localhost:%d/v3/paths/list", s.pass, s.apiPort) // credentials.
func (s *Actor) healthCheckURL() string {
return fmt.Sprintf("https://localhost:%d/v3/paths/list", s.apiPort)
} }
// shortID returns the first 12 characters of the given container ID. // shortID returns the first 12 characters of the given container ID.
@ -384,3 +480,17 @@ func generatePassword() string {
_, _ = rand.Read(p) _, _ = rand.Read(p)
return fmt.Sprintf("%x", []byte(p)) return fmt.Sprintf("%x", []byte(p))
} }
// toRTMPAddr builds a domain.NetAddr from an OptionalNetAddr, with default
// values set to RTMP default bind config if needed. If the OptionalNetAddr is
// not enabled, a zero value is returned.
func toRTMPAddr(a OptionalNetAddr, defaultPort int) domain.NetAddr {
if !a.Enabled {
return domain.NetAddr{}
}
return domain.NetAddr{
IP: cmp.Or(a.IP, defaultRTMPIP),
Port: cmp.Or(a.Port, defaultPort),
}
}

View File

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
) )
type httpClient interface { type httpClient interface {
@ -44,109 +43,37 @@ func buildAPIClient(certPEM []byte) (*http.Client, error) {
const userAgent = "octoplex-client" const userAgent = "octoplex-client"
type apiResponse[T any] struct { type apiPath struct {
Items []T `json:"items"`
}
type rtmpConnsResponse struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created"`
State string `json:"state"`
Path string `json:"path"`
BytesReceived int64 `json:"bytesReceived"`
BytesSent int64 `json:"bytesSent"`
RemoteAddr string `json:"remoteAddr"`
}
type ingressStreamState struct {
ready bool
listeners int
}
// TODO: handle pagination
func fetchIngressState(apiURL string, streamKey StreamKey, httpClient httpClient) (state ingressStreamState, _ error) {
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return state, fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
httpResp, err := httpClient.Do(req)
if err != nil {
return state, fmt.Errorf("do request: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
return state, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode)
}
respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return state, fmt.Errorf("read body: %w", err)
}
var resp apiResponse[rtmpConnsResponse]
if err = json.Unmarshal(respBody, &resp); err != nil {
return state, fmt.Errorf("unmarshal: %w", err)
}
for _, conn := range resp.Items {
if conn.Path != string(streamKey) {
continue
}
switch conn.State {
case "publish":
// mediamtx may report a stream as being in publish state via the API,
// but still refuse to serve them due to being unpublished. This seems to
// be a bug, this is a hacky workaround.
state.ready = conn.BytesReceived > 20_000
case "read":
state.listeners++
}
}
return state, nil
}
type path struct {
Name string `json:"name"` Name string `json:"name"`
Ready bool `json:"ready"`
Tracks []string `json:"tracks"` Tracks []string `json:"tracks"`
} }
// TODO: handle pagination func fetchPath(apiURL string, httpClient httpClient) (apiPath, error) {
func fetchTracks(apiURL string, streamKey StreamKey, httpClient httpClient) ([]string, error) {
req, err := http.NewRequest(http.MethodGet, apiURL, nil) req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("new request: %w", err) return apiPath{}, fmt.Errorf("new request: %w", err)
} }
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
httpResp, err := httpClient.Do(req) httpResp, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("do request: %w", err) return apiPath{}, fmt.Errorf("do request: %w", err)
} }
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode) return apiPath{}, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode)
} }
respBody, err := io.ReadAll(httpResp.Body) respBody, err := io.ReadAll(httpResp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("read body: %w", err) return apiPath{}, fmt.Errorf("read body: %w", err)
} }
var resp apiResponse[path] var path apiPath
if err = json.Unmarshal(respBody, &resp); err != nil { if err = json.Unmarshal(respBody, &path); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err) return apiPath{}, fmt.Errorf("unmarshal: %w", err)
} }
var tracks []string return path, nil
for _, path := range resp.Items {
if path.Name == string(streamKey) {
tracks = path.Tracks
}
}
return tracks, nil
} }

View File

@ -12,14 +12,14 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestFetchIngressState(t *testing.T) { func TestFetchPath(t *testing.T) {
const url = "http://localhost:8989/v3/rtmpconns/list" const url = "http://localhost:8989/v3/paths/get/live"
testCases := []struct { testCases := []struct {
name string name string
httpResponse *http.Response httpResponse *http.Response
httpError error httpError error
wantState ingressStreamState wantPath apiPath
wantErr error wantErr error
}{ }{
{ {
@ -36,36 +36,20 @@ func TestFetchIngressState(t *testing.T) {
wantErr: errors.New("unmarshal: invalid character 'i' looking for beginning of value"), wantErr: errors.New("unmarshal: invalid character 'i' looking for beginning of value"),
}, },
{ {
name: "successful response, no streams", name: "successful response, not ready",
httpResponse: &http.Response{ httpResponse: &http.Response{
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":0,"pageCount":0,"items":[]}`))), Body: io.NopCloser(bytes.NewReader([]byte(`{"name":"live","confName":"live","source":null,"ready":false,"readyTime":null,"tracks":[],"bytesReceived":0,"bytesSent":0,"readers":[]}`))),
}, },
wantState: ingressStreamState{ready: false, listeners: 0}, wantPath: apiPath{Name: "live", Ready: false, Tracks: []string{}},
},
{
name: "successful response, not yet ready",
httpResponse: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"id":"d2953cf8-9cd6-4c30-816f-807b80b6a71f","created":"2025-02-15T08:19:00.616220354Z","remoteAddr":"172.17.0.1:32972","state":"publish","path":"live","query":"","bytesReceived":15462,"bytesSent":3467}]}`))),
},
wantState: ingressStreamState{ready: false, listeners: 0},
}, },
{ {
name: "successful response, ready", name: "successful response, ready",
httpResponse: &http.Response{ httpResponse: &http.Response{
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"id":"d2953cf8-9cd6-4c30-816f-807b80b6a71f","created":"2025-02-15T08:19:00.616220354Z","remoteAddr":"172.17.0.1:32972","state":"publish","path":"live","query":"","bytesReceived":27832,"bytesSent":3467}]}`))), Body: io.NopCloser(bytes.NewReader([]byte(`{"name":"live","confName":"live","source":{"type":"rtmpConn","id":"fd2d79a8-bab9-4141-a1b5-55bd1a8649df"},"ready":true,"readyTime":"2025-04-18T07:44:53.683627506Z","tracks":["H264"],"bytesReceived":254677,"bytesSent":0,"readers":[]}`))),
}, },
wantState: ingressStreamState{ready: true, listeners: 0}, wantPath: apiPath{Name: "live", Ready: true, Tracks: []string{"H264"}},
},
{
name: "successful response, ready, with listeners",
httpResponse: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":2,"pageCount":1,"items":[{"id":"12668315-0572-41f1-8384-fe7047cc73be","created":"2025-02-15T08:23:43.836589664Z","remoteAddr":"172.17.0.1:40026","state":"publish","path":"live","query":"","bytesReceived":7180753,"bytesSent":3467},{"id":"079370fd-43bb-4798-b079-860cc3159e4e","created":"2025-02-15T08:24:32.396794364Z","remoteAddr":"192.168.48.3:44736","state":"read","path":"live","query":"","bytesReceived":333435,"bytesSent":24243}]}`))),
},
wantState: ingressStreamState{ready: true, listeners: 1},
}, },
} }
@ -79,74 +63,12 @@ func TestFetchIngressState(t *testing.T) {
})). })).
Return(tc.httpResponse, tc.httpError) Return(tc.httpResponse, tc.httpError)
state, err := fetchIngressState(url, StreamKey("live"), &httpClient) path, err := fetchPath(url, &httpClient)
if tc.wantErr != nil { if tc.wantErr != nil {
require.EqualError(t, err, tc.wantErr.Error()) require.EqualError(t, err, tc.wantErr.Error())
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.wantState, state) require.Equal(t, tc.wantPath, path)
}
})
}
}
func TestFetchTracks(t *testing.T) {
const url = "http://localhost:8989/v3/paths/list"
testCases := []struct {
name string
httpResponse *http.Response
httpError error
wantTracks []string
wantErr error
}{
{
name: "non-200 status",
httpResponse: &http.Response{StatusCode: http.StatusNotFound},
wantErr: errors.New("unexpected status code: 404"),
},
{
name: "unparseable response",
httpResponse: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte("invalid json"))),
},
wantErr: errors.New("unmarshal: invalid character 'i' looking for beginning of value"),
},
{
name: "successful response, no tracks",
httpResponse: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"name":"live","confName":"all_others","source":{"type":"rtmpConn","id":"287340b2-04c2-4fcc-ab9c-089f4ff15aeb"},"ready":true,"readyTime":"2025-02-22T17:26:05.527206818Z","tracks":[],"bytesReceived":94430983,"bytesSent":0,"readers":[]}]}`))),
},
wantTracks: []string{},
},
{
name: "successful response, tracks",
httpResponse: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"name":"live","confName":"all_others","source":{"type":"rtmpConn","id":"287340b2-04c2-4fcc-ab9c-089f4ff15aeb"},"ready":true,"readyTime":"2025-02-22T17:26:05.527206818Z","tracks":["H264","MPEG-4 Audio"],"bytesReceived":94430983,"bytesSent":0,"readers":[]}]}`))),
},
wantTracks: []string{"H264", "MPEG-4 Audio"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var httpClient mocks.HTTPClient
httpClient.
EXPECT().
Do(mock.MatchedBy(func(req *http.Request) bool {
return req.URL.String() == url && req.Method == http.MethodGet
})).
Return(tc.httpResponse, tc.httpError)
tracks, err := fetchTracks(url, StreamKey("live"), &httpClient)
if tc.wantErr != nil {
require.EqualError(t, err, tc.wantErr.Error())
} else {
require.NoError(t, err)
require.Equal(t, tc.wantTracks, tracks)
} }
}) })
} }

View File

@ -17,8 +17,12 @@ type Config struct {
APIEncryption bool `yaml:"apiEncryption,omitempty"` APIEncryption bool `yaml:"apiEncryption,omitempty"`
APIServerCert string `yaml:"apiServerCert,omitempty"` APIServerCert string `yaml:"apiServerCert,omitempty"`
APIServerKey string `yaml:"apiServerKey,omitempty"` APIServerKey string `yaml:"apiServerKey,omitempty"`
RTMP bool `yaml:"rtmp,omitempty"` RTMP bool `yaml:"rtmp"`
RTMPEncryption string `yaml:"rtmpEncryption,omitempty"`
RTMPAddress string `yaml:"rtmpAddress,omitempty"` RTMPAddress string `yaml:"rtmpAddress,omitempty"`
RTMPSAddress string `yaml:"rtmpsAddress,omitempty"`
RTMPServerCert string `yaml:"rtmpServerCert,omitempty"`
RTMPServerKey string `yaml:"rtmpServerKey,omitempty"`
HLS bool `yaml:"hls"` HLS bool `yaml:"hls"`
RTSP bool `yaml:"rtsp"` RTSP bool `yaml:"rtsp"`
WebRTC bool `yaml:"webrtc"` WebRTC bool `yaml:"webrtc"`

View File

@ -10,23 +10,20 @@ import (
"encoding/pem" "encoding/pem"
"math/big" "math/big"
"time" "time"
)
type ( "git.netflux.io/rob/octoplex/internal/domain"
tlsCert []byte
tlsKey []byte
) )
// generateTLSCert generates a self-signed TLS certificate and private key. // generateTLSCert generates a self-signed TLS certificate and private key.
func generateTLSCert() (tlsCert, tlsKey, error) { func generateTLSCert(dnsNames ...string) (domain.KeyPair, error) {
privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
now := time.Now() now := time.Now()
@ -40,28 +37,31 @@ func generateTLSCert() (tlsCert, tlsKey, error) {
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true, BasicConstraintsValid: true,
DNSNames: []string{"localhost"}, DNSNames: dnsNames,
} }
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
var certPEM, keyPEM bytes.Buffer var certPEM, keyPEM bytes.Buffer
if err = pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { if err = pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
privKeyDER, err := x509.MarshalECPrivateKey(privKey) privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
if err := pem.Encode(&keyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privKeyDER}); err != nil { if err := pem.Encode(&keyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privKeyDER}); err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
return certPEM.Bytes(), keyPEM.Bytes(), nil return domain.KeyPair{
Cert: certPEM.Bytes(),
Key: keyPEM.Bytes(),
}, nil
} }

View File

@ -12,12 +12,12 @@ import (
) )
func TestGenerateTLSCert(t *testing.T) { func TestGenerateTLSCert(t *testing.T) {
certPEM, keyPEM, err := generateTLSCert() keyPair, err := generateTLSCert("localhost", "rtmp.example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, certPEM) require.NotEmpty(t, keyPair.Cert)
require.NotEmpty(t, keyPEM) require.NotEmpty(t, keyPair.Key)
block, _ := pem.Decode(certPEM) block, _ := pem.Decode(keyPair.Cert)
require.NotNil(t, block, "failed to decode certificate PEM") require.NotNil(t, block, "failed to decode certificate PEM")
cert, err := x509.ParseCertificate(block.Bytes) cert, err := x509.ParseCertificate(block.Bytes)
@ -33,8 +33,10 @@ func TestGenerateTLSCert(t *testing.T) {
assert.True(t, cert.BasicConstraintsValid, "basic constraints should be valid") assert.True(t, cert.BasicConstraintsValid, "basic constraints should be valid")
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth) assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth) assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
assert.Contains(t, cert.DNSNames, "localhost", "DNS names should include localhost")
assert.Contains(t, cert.DNSNames, "rtmp.example.com", "DNS names should include rtmp.example.com")
block, _ = pem.Decode(keyPEM) block, _ = pem.Decode(keyPair.Key)
require.NotNil(t, block, "failed to decode private key PEM") require.NotNil(t, block, "failed to decode private key PEM")
privKey, err := x509.ParseECPrivateKey(block.Bytes) privKey, err := x509.ParseECPrivateKey(block.Bytes)

View File

@ -1,11 +1,13 @@
package multiplexer package replicator
import ( import (
"cmp" "cmp"
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@ -19,19 +21,19 @@ type action func()
const ( const (
defaultChanSize = 64 // default channel size for asynchronous non-error channels defaultChanSize = 64 // default channel size for asynchronous non-error channels
componentName = "multiplexer" // component name, mostly used for Docker labels componentName = "replicator" // component name, mostly used for Docker labels
imageNameFFMPEG = "ghcr.io/jrottenberg/ffmpeg:7.1-scratch" // image name for ffmpeg imageNameFFMPEG = "ghcr.io/jrottenberg/ffmpeg:7.1-scratch" // image name for ffmpeg
) )
// State is the state of a single destination from the point of view of the // State is the state of a single destination from the point of view of the
// multiplexer. // replicator.
type State struct { type State struct {
URL string URL string
Container domain.Container Container domain.Container
Status domain.DestinationStatus Status domain.DestinationStatus
} }
// Actor is responsible for managing the multiplexer. // Actor is responsible for managing the replicator.
type Actor struct { type Actor struct {
wg sync.WaitGroup wg sync.WaitGroup
ctx context.Context ctx context.Context
@ -43,22 +45,23 @@ type Actor struct {
stateC chan State stateC chan State
// mutable state // mutable state
currURLs map[string]struct{} currURLs map[string]struct{}
nextIndex int nextIndex int
} }
// NewActorParams contains the parameters for starting a new multiplexer actor. // StartActorParams contains the parameters for starting a new replicator actor.
type NewActorParams struct { type StartActorParams struct {
SourceURL string SourceURL string
ChanSize int ChanSize int
ContainerClient *container.Client ContainerClient *container.Client
Logger *slog.Logger Logger *slog.Logger
} }
// NewActor starts a new multiplexer actor. // StartActor starts a new replicator actor.
// //
// The channel exposed by [C] must be consumed by the caller. // The channel exposed by [C] must be consumed by the caller.
func NewActor(ctx context.Context, params NewActorParams) *Actor { func StartActor(ctx context.Context, params StartActorParams) *Actor {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
actor := &Actor{ actor := &Actor{
@ -94,6 +97,7 @@ func (a *Actor) StartDestination(url string) {
ContainerConfig: &typescontainer.Config{ ContainerConfig: &typescontainer.Config{
Image: imageNameFFMPEG, Image: imageNameFFMPEG,
Cmd: []string{ Cmd: []string{
"-loglevel", "level+error",
"-i", a.sourceURL, "-i", a.sourceURL,
"-c", "copy", "-c", "copy",
"-f", "flv", "-f", "flv",
@ -104,11 +108,20 @@ func (a *Actor) StartDestination(url string) {
container.LabelURL: url, container.LabelURL: url,
}, },
}, },
HostConfig: &typescontainer.HostConfig{ HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
NetworkMode: "default",
RestartPolicy: typescontainer.RestartPolicy{Name: "always"},
},
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth1", Tx: "eth0"}, NetworkCountConfig: container.NetworkCountConfig{Rx: "eth1", Tx: "eth0"},
Logs: container.LogConfig{Stderr: true},
ShouldRestart: func(_ int64, restartCount int, logs [][]byte, runningTime time.Duration) (bool, error) {
// Try to infer if the container failed to start.
//
// For now, we just check if it was running for less than ten seconds.
if restartCount == 0 && runningTime < 10*time.Second {
return false, containerStartErrFromLogs(logs)
}
// Otherwise, always restart, regardless of the exit code.
return true, nil
},
}) })
a.wg.Add(1) a.wg.Add(1)
@ -120,6 +133,32 @@ func (a *Actor) StartDestination(url string) {
} }
} }
// Grab the first fatal log line, if it exists, or the first error log line,
// from the FFmpeg output.
func containerStartErrFromLogs(logs [][]byte) error {
var fatalLog, errLog string
for _, logBytes := range logs {
log := string(logBytes)
if strings.HasPrefix(log, "[fatal]") {
fatalLog = log
break
}
}
if fatalLog == "" {
for _, logBytes := range logs {
log := string(logBytes)
if strings.HasPrefix(log, "[error]") {
errLog = log
break
}
}
}
return errors.New(cmp.Or(fatalLog, errLog, "container failed to start"))
}
// StopDestination stops a destination stream. // StopDestination stops a destination stream.
func (a *Actor) StopDestination(url string) { func (a *Actor) StopDestination(url string) {
a.actorC <- func() { a.actorC <- func() {
@ -175,7 +214,7 @@ func (a *Actor) destLoop(url string, containerStateC <-chan domain.Container, er
} }
} }
// C returns a channel that will receive the current state of the multiplexer. // C returns a channel that will receive the current state of the replicator.
// The channel is never closed. // The channel is never closed.
func (a *Actor) C() <-chan State { func (a *Actor) C() <-chan State {
return a.stateC return a.stateC

View File

@ -0,0 +1,51 @@
package replicator
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestContainerStartErrFromLogs(t *testing.T) {
testCases := []struct {
name string
logs [][]byte
want error
}{
{
name: "no logs",
want: errors.New("container failed to start"),
},
{
name: "with only error logs",
logs: [][]byte{
[]byte("[error] this is an error log"),
[]byte("[error] this is another error log"),
},
want: errors.New("[error] this is an error log"),
},
{
name: "with only fatal logs",
logs: [][]byte{
[]byte("[fatal] this is a fatal log"),
[]byte("[fatal] this is another fatal log"),
},
want: errors.New("[fatal] this is a fatal log"),
},
{
name: "with error and fatal logs",
logs: [][]byte{
[]byte("[error] this is an error log"),
[]byte("[fatal] this is a fatal log"),
},
want: errors.New("[fatal] this is a fatal log"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, containerStartErrFromLogs(tc.logs))
})
}
}

View File

@ -13,13 +13,13 @@ import (
"time" "time"
"git.netflux.io/rob/octoplex/internal/domain" "git.netflux.io/rob/octoplex/internal/domain"
"git.netflux.io/rob/octoplex/internal/shortid"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
"golang.design/x/clipboard" "golang.design/x/clipboard"
) )
type sourceViews struct { type sourceViews struct {
url *tview.TextView
status *tview.TextView status *tview.TextView
tracks *tview.TextView tracks *tview.TextView
health *tview.TextView health *tview.TextView
@ -40,9 +40,12 @@ const (
// UI is responsible for managing the terminal user interface. // UI is responsible for managing the terminal user interface.
type UI struct { type UI struct {
commandCh chan Command commandC chan domain.Command
buildInfo domain.BuildInfo clipboardAvailable bool
logger *slog.Logger configFilePath string
rtmpURL, rtmpsURL string
buildInfo domain.BuildInfo
logger *slog.Logger
// tview state // tview state
@ -50,15 +53,26 @@ type UI struct {
screen tcell.Screen screen tcell.Screen
screenCaptureC chan<- ScreenCapture screenCaptureC chan<- ScreenCapture
pages *tview.Pages pages *tview.Pages
container *tview.Flex
sourceViews sourceViews sourceViews sourceViews
destView *tview.Table destView *tview.Table
noDestView *tview.TextView
aboutView *tview.Flex
pullProgressModal *tview.Modal pullProgressModal *tview.Modal
// other mutable state // other mutable state
mu sync.Mutex mu sync.Mutex
urlsToStartState map[string]startState urlsToStartState map[string]startState
allowQuit bool
/// addingDestination is true if add destination modal is currently visible.
addingDestination bool
// hasDestinations is true if the UI thinks there are destinations
// configured.
hasDestinations bool
// lastSelectedDestIndex is the index of the last selected destination, starting
// at 1 (because 0 is the header).
lastSelectedDestIndex int
} }
// Screen represents a terminal screen. This includes its desired dimensions, // Screen represents a terminal screen. This includes its desired dimensions,
@ -92,7 +106,7 @@ const defaultChanSize = 64
// StartUI starts the terminal user interface. // StartUI starts the terminal user interface.
func StartUI(ctx context.Context, params StartParams) (*UI, error) { func StartUI(ctx context.Context, params StartParams) (*UI, error) {
chanSize := cmp.Or(params.ChanSize, defaultChanSize) chanSize := cmp.Or(params.ChanSize, defaultChanSize)
commandCh := make(chan Command, chanSize) commandCh := make(chan domain.Command, chanSize)
app := tview.NewApplication() app := tview.NewApplication()
@ -114,8 +128,8 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
sourceView := tview.NewFlex() sourceView := tview.NewFlex()
sourceView.SetDirection(tview.FlexColumn) sourceView.SetDirection(tview.FlexColumn)
sourceView.SetBorder(true) sourceView.SetBorder(true)
sourceView.SetTitle("Ingress RTMP server") sourceView.SetTitle("Source")
sidebar.AddItem(sourceView, 9, 0, false) sidebar.AddItem(sourceView, 8, 0, false)
leftCol := tview.NewFlex() leftCol := tview.NewFlex()
leftCol.SetDirection(tview.FlexRow) leftCol.SetDirection(tview.FlexRow)
@ -124,11 +138,6 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
sourceView.AddItem(leftCol, 9, 0, false) sourceView.AddItem(leftCol, 9, 0, false)
sourceView.AddItem(rightCol, 0, 1, false) sourceView.AddItem(rightCol, 0, 1, false)
urlHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerURL)
leftCol.AddItem(urlHeaderTextView, 1, 0, false)
urlTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
rightCol.AddItem(urlTextView, 1, 0, false)
statusHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerStatus) statusHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerStatus)
leftCol.AddItem(statusHeaderTextView, 1, 0, false) leftCol.AddItem(statusHeaderTextView, 1, 0, false)
statusTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash) statusTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
@ -163,17 +172,11 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
aboutView.SetDirection(tview.FlexRow) aboutView.SetDirection(tview.FlexRow)
aboutView.SetBorder(true) aboutView.SetBorder(true)
aboutView.SetTitle("Actions") aboutView.SetTitle("Actions")
aboutView.AddItem(tview.NewTextView().SetText("[a] Add new destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[r] Remove destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[Space] Toggle destination"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[u] Copy ingress RTMP URL"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[c] Copy config file path"), 1, 0, false)
aboutView.AddItem(tview.NewTextView().SetText("[?] About"), 1, 0, false)
sidebar.AddItem(aboutView, 0, 1, false) sidebar.AddItem(aboutView, 0, 1, false)
destView := tview.NewTable() destView := tview.NewTable()
destView.SetTitle("Egress streams") destView.SetTitle("Destinations")
destView.SetBorder(true) destView.SetBorder(true)
destView.SetSelectable(true, false) destView.SetSelectable(true, false)
destView.SetWrapSelection(true, false) destView.SetWrapSelection(true, false)
@ -185,28 +188,39 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
SetTextColor(tcell.ColorWhite). SetTextColor(tcell.ColorWhite).
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
flex := tview.NewFlex(). container := tview.NewFlex().
SetDirection(tview.FlexColumn). SetDirection(tview.FlexColumn).
AddItem(sidebar, 40, 0, false). AddItem(sidebar, 40, 0, false).
AddItem(destView, 0, 6, false) AddItem(destView, 0, 6, false)
// noDestView is overlaid on top of the main view when there are no
// destinations configured.
noDestView := tview.NewTextView().
SetText(`No destinations added yet. Press [a] to add a new destination.`).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.ColorGrey)
noDestView.SetBorder(false)
pages := tview.NewPages() pages := tview.NewPages()
pages.AddPage(pageNameMain, flex, true, true) pages.AddPage(pageNameMain, container, true, true)
pages.AddPage(pageNameNoDestinations, noDestView, false, false)
app.SetRoot(pages, true) app.SetRoot(pages, true)
app.SetFocus(destView) app.SetFocus(destView)
app.EnableMouse(false) app.EnableMouse(false)
ui := &UI{ ui := &UI{
commandCh: commandCh, commandC: commandCh,
buildInfo: params.BuildInfo, clipboardAvailable: params.ClipboardAvailable,
logger: params.Logger, configFilePath: params.ConfigFilePath,
app: app, buildInfo: params.BuildInfo,
screen: screen, logger: params.Logger,
screenCaptureC: screenCaptureC, app: app,
pages: pages, screen: screen,
screenCaptureC: screenCaptureC,
pages: pages,
container: container,
sourceViews: sourceViews{ sourceViews: sourceViews{
url: urlTextView,
status: statusTextView, status: statusTextView,
tracks: tracksTextView, tracks: tracksTextView,
health: healthTextView, health: healthTextView,
@ -215,66 +229,51 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
rx: rxTextView, rx: rxTextView,
}, },
destView: destView, destView: destView,
noDestView: noDestView,
aboutView: aboutView,
pullProgressModal: pullProgressModal, pullProgressModal: pullProgressModal,
urlsToStartState: make(map[string]startState), urlsToStartState: make(map[string]startState),
} }
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { app.SetInputCapture(ui.inputCaptureHandler)
// Special case: allow all keys except Escape to be passed to the add app.SetAfterDrawFunc(ui.afterDrawHandler)
// destination modal.
//
// TODO: catch Ctrl-c
if pageName, _ := pages.GetFrontPage(); pageName == pageNameAddDestination {
if event.Key() == tcell.KeyEscape {
ui.closeAddDestinationForm()
return nil
}
return event
}
switch event.Key() {
case tcell.KeyRune:
switch event.Rune() {
case 'a', 'A':
ui.addDestination()
return nil
case 'r', 'R':
ui.removeDestination()
return nil
case ' ':
ui.toggleDestination()
case 'u', 'U':
ui.copySourceURLToClipboard(params.ClipboardAvailable)
case 'c', 'C':
ui.copyConfigFilePathToClipboard(params.ClipboardAvailable, params.ConfigFilePath)
case '?':
ui.showAbout()
}
case tcell.KeyCtrlC:
ui.confirmQuit()
return nil
}
return event
})
if ui.screenCaptureC != nil {
app.SetAfterDrawFunc(ui.captureScreen)
}
go ui.run(ctx) go ui.run(ctx)
return ui, nil return ui, nil
} }
func (ui *UI) renderAboutView() {
ui.aboutView.Clear()
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]a[-] Add destination"), 1, 0, false)
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Del[-] Remove destination"), 1, 0, false)
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Space[-] Start/stop destination"), 1, 0, false)
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText(""), 1, 0, false)
i := 1
if ui.rtmpURL != "" {
rtmpURLView := tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf("[grey]F%d[-] Copy source RTMP URL", i))
ui.aboutView.AddItem(rtmpURLView, 1, 0, false)
i++
}
if ui.rtmpsURL != "" {
rtmpsURLView := tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf("[grey]F%d[-] Copy source RTMPS URL", i))
ui.aboutView.AddItem(rtmpsURLView, 1, 0, false)
}
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]c[-] Copy config file path"), 1, 0, false)
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]?[-] About"), 1, 0, false)
}
// C returns a channel that receives commands from the user interface. // C returns a channel that receives commands from the user interface.
func (ui *UI) C() <-chan Command { func (ui *UI) C() <-chan domain.Command {
return ui.commandCh return ui.commandC
} }
func (ui *UI) run(ctx context.Context) { func (ui *UI) run(ctx context.Context) {
defer close(ui.commandCh) defer close(ui.commandC)
uiDone := make(chan struct{}) uiDone := make(chan struct{})
go func() { go func() {
@ -297,12 +296,92 @@ func (ui *UI) run(ctx context.Context) {
} }
} }
// SetRTMPURLs sets the RTMP and RTMPS URLs for the user interface, which are
// unavailable when the UI is first created.
func (ui *UI) SetRTMPURLs(rtmpURL, rtmpsURL string) {
ui.mu.Lock()
ui.rtmpURL = rtmpURL
ui.rtmpsURL = rtmpsURL
ui.mu.Unlock()
ui.renderAboutView()
}
func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
// Special case: handle CTRL-C even when a modal is visible.
if event.Key() == tcell.KeyCtrlC {
ui.confirmQuit()
return nil
}
if ui.modalVisible() {
return event
}
handleKeyUp := func() {
row, _ := ui.destView.GetSelection()
if row == 1 {
ui.destView.Select(ui.destView.GetRowCount(), 0)
}
}
switch event.Key() {
case tcell.KeyRune:
switch event.Rune() {
case 'a', 'A':
ui.addDestination()
return nil
case ' ':
ui.toggleDestination()
case 'c', 'C':
ui.copyConfigFilePathToClipboard(ui.clipboardAvailable, ui.configFilePath)
case '?':
ui.showAbout()
case 'k': // tview vim bindings
handleKeyUp()
}
case tcell.KeyF1, tcell.KeyF2:
ui.fkeyHandler(event.Key())
case tcell.KeyDelete, tcell.KeyBackspace, tcell.KeyBackspace2:
ui.removeDestination()
return nil
case tcell.KeyUp:
handleKeyUp()
}
return event
}
func (ui *UI) fkeyHandler(key tcell.Key) {
var urls []string
if ui.rtmpURL != "" {
urls = append(urls, ui.rtmpURL)
}
if ui.rtmpsURL != "" {
urls = append(urls, ui.rtmpsURL)
}
switch key {
case tcell.KeyF1:
if len(urls) == 0 {
return
}
ui.copySourceURLToClipboard(urls[0])
case tcell.KeyF2:
if len(urls) < 2 {
return
}
ui.copySourceURLToClipboard(urls[1])
}
}
func (ui *UI) ShowSourceNotLiveModal() { func (ui *UI) ShowSourceNotLiveModal() {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
pageNameModalStartupCheck, pageNameModalNotLive,
fmt.Sprintf("Source is not live.\nStart streaming to the source URL then try again:\n\n%s", ui.sourceViews.url.GetText(true)), "Waiting for stream.\n\nStart streaming to a source URL then try again.",
[]string{"Ok"}, []string{"Ok"},
false,
nil, nil,
) )
}) })
@ -320,11 +399,11 @@ func (ui *UI) ShowStartupCheckModal() bool {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
pageNameModalStartupCheck, pageNameModalStartupCheck,
"Another instance of Octoplex may already be running. Pressing continue will close that instance. Continue?", "Another instance of Octoplex may already be running.\n\nPressing continue will close that instance. Continue?",
[]string{"Continue", "Exit"}, []string{"Continue", "Exit"},
false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.app.SetFocus(ui.destView)
done <- true done <- true
} else { } else {
done <- false done <- false
@ -337,37 +416,46 @@ func (ui *UI) ShowStartupCheckModal() bool {
} }
func (ui *UI) ShowDestinationErrorModal(name string, err error) { func (ui *UI) ShowDestinationErrorModal(name string, err error) {
done := make(chan struct{})
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.showModal( ui.showModal(
pageNameModalStartupCheck, pageNameModalDestinationError,
fmt.Sprintf( fmt.Sprintf(
"Streaming to %s failed:\n\n%s", "Streaming to %s failed:\n\n%s",
cmp.Or(name, "this destination"), cmp.Or(name, "this destination"),
err, err,
), ),
[]string{"Ok"}, []string{"Ok"},
true,
nil,
)
})
}
// ShowFatalErrorModal displays the provided error. It sends a CommandQuit to the
// command channel when the user selects the Quit button.
func (ui *UI) ShowFatalErrorModal(errString string) {
ui.app.QueueUpdateDraw(func() {
ui.showModal(
pageNameModalFatalError,
fmt.Sprintf(
"An error occurred:\n\n%s",
errString,
),
[]string{"Quit"},
false,
func(int, string) { func(int, string) {
done <- struct{}{} ui.commandC <- domain.CommandQuit{}
}, },
) )
}) })
<-done
} }
// AllowQuit enables the quit action. func (ui *UI) afterDrawHandler(screen tcell.Screen) {
func (ui *UI) AllowQuit() { if ui.screenCaptureC == nil {
ui.mu.Lock() return
defer ui.mu.Unlock() }
// This is required to prevent the user from quitting during the startup ui.captureScreen(screen)
// check modal, when the main event loop is not yet running, and avoid an
// unexpected user experience. It might be nice to find a way to remove this
// but it probably means refactoring the mediaserver actor to separate
// starting the server from starting the event loop.
ui.allowQuit = true
} }
// captureScreen captures the screen and sends it to the screenCaptureC // captureScreen captures the screen and sends it to the screenCaptureC
@ -378,7 +466,8 @@ func (ui *UI) AllowQuit() {
func (ui *UI) captureScreen(screen tcell.Screen) { func (ui *UI) captureScreen(screen tcell.Screen) {
simScreen, ok := screen.(tcell.SimulationScreen) simScreen, ok := screen.(tcell.SimulationScreen)
if !ok { if !ok {
ui.logger.Error("simulation screen not available") ui.logger.Warn("captureScreen: simulation screen not available")
return
} }
cells, w, h := simScreen.GetContents() cells, w, h := simScreen.GetContents()
@ -401,6 +490,8 @@ func (ui *UI) SetState(state domain.AppState) {
for _, dest := range state.Destinations { for _, dest := range state.Destinations {
ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status) ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status)
} }
ui.hasDestinations = len(state.Destinations) > 0
ui.mu.Unlock() ui.mu.Unlock()
// The state is mutable so can't be passed into QueueUpdateDraw, which // The state is mutable so can't be passed into QueueUpdateDraw, which
@ -475,17 +566,88 @@ func (ui *UI) updateProgressModal(container domain.Container) {
const ( const (
pageNameMain = "main" pageNameMain = "main"
pageNameAddDestination = "add-destination" pageNameAddDestination = "add-destination"
pageNameModalAbout = "modal-about" pageNameViewURLs = "view-urls"
pageNameModalQuit = "modal-quit"
pageNameModalStartupCheck = "modal-startup-check"
pageNameModalClipboard = "modal-clipboard"
pageNameModalPullProgress = "modal-pull-progress"
pageNameModalRemoveDestination = "modal-remove-destination"
pageNameConfigUpdateFailed = "modal-config-update-failed" pageNameConfigUpdateFailed = "modal-config-update-failed"
pageNameNoDestinations = "no-destinations"
pageNameModalAbout = "modal-about"
pageNameModalClipboard = "modal-clipboard"
pageNameModalDestinationError = "modal-destination-error"
pageNameModalFatalError = "modal-fatal-error"
pageNameModalPullProgress = "modal-pull-progress"
pageNameModalQuit = "modal-quit"
pageNameModalRemoveDestination = "modal-remove-destination"
pageNameModalSourceError = "modal-source-error"
pageNameModalStartupCheck = "modal-startup-check"
pageNameModalNotLive = "modal-not-live"
) )
func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc func(int, string)) { // modalVisible returns true if any modal, including the add destination form,
if ui.pages.HasPage(pageName) { // is visible.
func (ui *UI) modalVisible() bool {
pageName, _ := ui.pages.GetFrontPage()
return pageName != pageNameMain && pageName != pageNameNoDestinations
}
// saveSelectedDestination saves the last selected destination index to local
// mutable state.
//
// This is needed so that the user's selection can be restored
// after redrawing the screen. It may be possible to remove this if we can
// re-render the screen more selectively instead of calling [redrawFromState]
// every time the state changes.
func (ui *UI) saveSelectedDestination() {
row, _ := ui.destView.GetSelection()
ui.mu.Lock()
ui.lastSelectedDestIndex = row
ui.mu.Unlock()
}
// selectPreviousDestination sets the focus to the last-selected destination.
func (ui *UI) selectPreviousDestination() {
if ui.modalVisible() {
return
}
var row int
ui.mu.Lock()
row = ui.lastSelectedDestIndex
ui.mu.Unlock()
// If the last element has been removed, select the new last element.
row = min(ui.destView.GetRowCount()-1, row)
ui.app.SetFocus(ui.destView)
if row == 0 {
return
}
ui.destView.Select(row, 0)
}
// selectLastDestination sets the user selection to the last destination.
func (ui *UI) selectLastDestination() {
if ui.modalVisible() {
return
}
ui.app.SetFocus(ui.destView)
if rowCount := ui.destView.GetRowCount(); rowCount > 1 {
ui.destView.Select(rowCount-1, 0)
}
}
func (ui *UI) showModal(
pageName string,
text string,
buttons []string,
allowMultiple bool,
doneFunc func(int, string),
) {
if allowMultiple {
pageName = pageName + "-" + shortid.New().String()
} else if ui.pages.HasPage(pageName) {
return return
} }
@ -497,16 +659,20 @@ func (ui *UI) showModal(pageName string, text string, buttons []string, doneFunc
SetDoneFunc(func(buttonIndex int, buttonLabel string) { SetDoneFunc(func(buttonIndex int, buttonLabel string) {
ui.pages.RemovePage(pageName) ui.pages.RemovePage(pageName)
if name, _ := ui.pages.GetFrontPage(); name == pageNameMain { if !ui.modalVisible() {
ui.app.SetFocus(ui.destView) ui.app.SetInputCapture(ui.inputCaptureHandler)
} }
if doneFunc != nil { if doneFunc != nil {
doneFunc(buttonIndex, buttonLabel) doneFunc(buttonIndex, buttonLabel)
} }
ui.selectPreviousDestination()
}). }).
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.saveSelectedDestination()
ui.pages.AddPage(pageName, modal, true, true) ui.pages.AddPage(pageName, modal, true, true)
} }
@ -520,26 +686,23 @@ func (ui *UI) hideModal(pageName string) {
} }
func (ui *UI) handleMediaServerClosed(exitReason string) { func (ui *UI) handleMediaServerClosed(exitReason string) {
done := make(chan struct{})
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
if ui.pages.HasPage(pageNameModalSourceError) {
return
}
modal := tview.NewModal() modal := tview.NewModal()
modal.SetText("Mediaserver error: " + exitReason). modal.SetText("Mediaserver error: " + exitReason).
AddButtons([]string{"Quit"}). AddButtons([]string{"Quit"}).
SetBackgroundColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite). SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) { SetDoneFunc(func(int, string) {
// TODO: improve app cleanup ui.commandC <- domain.CommandQuit{}
done <- struct{}{}
ui.app.Stop()
}) })
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage("modal", modal, true, true) ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
}) })
<-done
} }
const dash = "—" const dash = "—"
@ -558,6 +721,22 @@ const (
) )
func (ui *UI) redrawFromState(state domain.AppState) { func (ui *UI) redrawFromState(state domain.AppState) {
var addingDestination bool
ui.mu.Lock()
addingDestination = ui.addingDestination
ui.mu.Unlock()
var showNoDestinationsPage bool
if len(state.Destinations) == 0 && !addingDestination {
showNoDestinationsPage = true
}
if showNoDestinationsPage {
x, y, w, _ := ui.destView.GetRect()
ui.noDestView.SetRect(x+5, y+4, w-10, 3)
ui.pages.ShowPage(pageNameNoDestinations)
}
headerCell := func(content string, expansion int) *tview.TableCell { headerCell := func(content string, expansion int) *tview.TableCell {
return tview. return tview.
NewTableCell(content). NewTableCell(content).
@ -566,8 +745,6 @@ func (ui *UI) redrawFromState(state domain.AppState) {
SetSelectable(false) SetSelectable(false)
} }
ui.sourceViews.url.SetText(state.Source.RTMPURL)
tracks := dash tracks := dash
if state.Source.Live && len(state.Source.Tracks) > 0 { if state.Source.Live && len(state.Source.Tracks) > 0 {
tracks = strings.Join(state.Source.Tracks, ", ") tracks = strings.Join(state.Source.Tracks, ", ")
@ -582,7 +759,7 @@ func (ui *UI) redrawFromState(state domain.AppState) {
ui.sourceViews.status.SetText("[black:green]receiving" + durStr) ui.sourceViews.status.SetText("[black:green]receiving" + durStr)
} else if state.Source.Container.Status == domain.ContainerStatusRunning && state.Source.Container.HealthState == "healthy" { } else if state.Source.Container.Status == domain.ContainerStatusRunning && state.Source.Container.HealthState == "healthy" {
ui.sourceViews.status.SetText("[black:yellow]waiting") ui.sourceViews.status.SetText("[black:yellow]waiting for stream")
} else { } else {
ui.sourceViews.status.SetText("[white:red]not ready") ui.sourceViews.status.SetText("[white:red]not ready")
} }
@ -679,6 +856,7 @@ func (ui *UI) ConfigUpdateFailed(err error) {
pageNameConfigUpdateFailed, pageNameConfigUpdateFailed,
"Configuration update failed:\n\n"+err.Error(), "Configuration update failed:\n\n"+err.Error(),
[]string{"Ok"}, []string{"Ok"},
false,
func(int, string) { func(int, string) {
pageName, frontPage := ui.pages.GetFrontPage() pageName, frontPage := ui.pages.GetFrontPage()
if pageName != pageNameAddDestination { if pageName != pageNameAddDestination {
@ -702,29 +880,43 @@ func (ui *UI) addDestination() {
) )
var currWidth, currHeight int var currWidth, currHeight int
if name, frontPage := ui.pages.GetFrontPage(); name == pageNameMain { _, _, currWidth, currHeight = ui.container.GetRect()
_, _, currWidth, currHeight = frontPage.GetRect()
} else {
return
}
form := tview.NewForm() form := tview.NewForm()
form. form.
AddInputField(inputLabelName, "My stream", inputLen, nil, nil). AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil). AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
AddButton("Add", func() { AddButton("Add", func() {
ui.commandCh <- CommandAddDestination{ ui.commandC <- domain.CommandAddDestination{
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(), DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(), URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
} }
}). }).
AddButton("Cancel", func() { ui.closeAddDestinationForm() }). AddButton("Cancel", func() {
ui.closeAddDestinationForm()
ui.selectPreviousDestination()
}).
SetFieldBackgroundColor(tcell.ColorDarkSlateGrey). SetFieldBackgroundColor(tcell.ColorDarkSlateGrey).
SetBorder(true). SetBorder(true).
SetTitle("Add a new destination"). SetTitle("Add a new destination").
SetTitleAlign(tview.AlignLeft). SetTitleAlign(tview.AlignLeft).
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
ui.closeAddDestinationForm()
ui.selectPreviousDestination()
return nil
}
return event
}).
SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight) SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight)
ui.mu.Lock()
ui.addingDestination = true
ui.mu.Unlock()
ui.saveSelectedDestination()
ui.pages.HidePage(pageNameNoDestinations)
ui.pages.AddPage(pageNameAddDestination, form, false, true) ui.pages.AddPage(pageNameAddDestination, form, false, true)
} }
@ -750,23 +942,44 @@ func (ui *UI) removeDestination() {
pageNameModalRemoveDestination, pageNameModalRemoveDestination,
text, text,
[]string{"Remove", "Cancel"}, []string{"Remove", "Cancel"},
false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandCh <- CommandRemoveDestination{URL: url} ui.commandC <- domain.CommandRemoveDestination{URL: url}
} }
}, },
) )
} }
// DestinationAdded should be called when a new destination is added.
func (ui *UI) DestinationAdded() { func (ui *UI) DestinationAdded() {
ui.mu.Lock()
ui.hasDestinations = true
ui.mu.Unlock()
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.pages.HidePage(pageNameNoDestinations)
ui.closeAddDestinationForm() ui.closeAddDestinationForm()
ui.selectLastDestination()
}) })
} }
// DestinationRemoved should be called when a destination is removed.
func (ui *UI) DestinationRemoved() {
ui.selectPreviousDestination()
}
func (ui *UI) closeAddDestinationForm() { func (ui *UI) closeAddDestinationForm() {
var hasDestinations bool
ui.mu.Lock()
ui.addingDestination = false
hasDestinations = ui.hasDestinations
ui.mu.Unlock()
ui.pages.RemovePage(pageNameAddDestination) ui.pages.RemovePage(pageNameAddDestination)
ui.app.SetFocus(ui.destView) if !hasDestinations {
ui.pages.ShowPage(pageNameNoDestinations)
}
} }
func (ui *UI) toggleDestination() { func (ui *UI) toggleDestination() {
@ -777,7 +990,7 @@ func (ui *UI) toggleDestination() {
return return
} }
// Communicating with the multiplexer/container client is asynchronous. To // Communicating with the replicator/container client is asynchronous. To
// ensure we can limit each destination to a single container we need some // ensure we can limit each destination to a single container we need some
// kind of local mutable state which synchronously tracks the "start state" // kind of local mutable state which synchronously tracks the "start state"
// of each destination. // of each destination.
@ -796,22 +1009,21 @@ func (ui *UI) toggleDestination() {
switch ss { switch ss {
case startStateNotStarted: case startStateNotStarted:
ui.urlsToStartState[url] = startStateStarting ui.urlsToStartState[url] = startStateStarting
ui.commandCh <- CommandStartDestination{URL: url} ui.commandC <- domain.CommandStartDestination{URL: url}
case startStateStarting: case startStateStarting:
// do nothing // do nothing
return return
case startStateStarted: case startStateStarted:
ui.commandCh <- CommandStopDestination{URL: url} ui.commandC <- domain.CommandStopDestination{URL: url}
} }
} }
func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) { func (ui *UI) copySourceURLToClipboard(url string) {
var text string var text string
url := ui.sourceViews.url.GetText(true) if ui.clipboardAvailable {
if clipboardAvailable {
clipboard.Write(clipboard.FmtText, []byte(url)) clipboard.Write(clipboard.FmtText, []byte(url))
text = "Ingress URL copied to clipboard:\n\n" + url text = "URL copied to clipboard:\n\n" + url
} else { } else {
text = "Copy to clipboard not available:\n\n" + url text = "Copy to clipboard not available:\n\n" + url
} }
@ -820,6 +1032,7 @@ func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) {
pageNameModalClipboard, pageNameModalClipboard,
text, text,
[]string{"Ok"}, []string{"Ok"},
false,
nil, nil,
) )
} }
@ -841,28 +1054,20 @@ func (ui *UI) copyConfigFilePathToClipboard(clipboardAvailable bool, configFileP
pageNameModalClipboard, pageNameModalClipboard,
text, text,
[]string{"Ok"}, []string{"Ok"},
false,
nil, nil,
) )
} }
func (ui *UI) confirmQuit() { func (ui *UI) confirmQuit() {
var allowQuit bool
ui.mu.Lock()
allowQuit = ui.allowQuit
ui.mu.Unlock()
if !allowQuit {
return
}
ui.showModal( ui.showModal(
pageNameModalQuit, pageNameModalQuit,
"Are you sure you want to quit?", "Are you sure you want to quit?",
[]string{"Quit", "Cancel"}, []string{"Quit", "Cancel"},
false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandCh <- CommandQuit{} ui.commandC <- domain.CommandQuit{}
return
} }
}, },
) )
@ -877,7 +1082,7 @@ func (ui *UI) showAbout() {
ui.showModal( ui.showModal(
pageNameModalAbout, pageNameModalAbout,
fmt.Sprintf( fmt.Sprintf(
"%s: live stream multiplexer\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).", "%s: live stream replicator\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).",
domain.AppName, domain.AppName,
cmp.Or(ui.buildInfo.Version, "0.0.0-devel"), cmp.Or(ui.buildInfo.Version, "0.0.0-devel"),
cmp.Or(commit, "unknown SHA"), cmp.Or(commit, "unknown SHA"),
@ -885,6 +1090,7 @@ func (ui *UI) showAbout() {
ui.buildInfo.GoVersion, ui.buildInfo.GoVersion,
), ),
[]string{"Ok"}, []string{"Ok"},
false,
nil, nil,
) )
} }

View File

@ -70,7 +70,7 @@ func TestRightPad(t *testing.T) {
want: "foo ", want: "foo ",
}, },
{ {
name: "string with equal lenth to required width", name: "string with length equal to required width",
input: "foobar", input: "foobar",
want: "foobar", want: "foobar",
}, },

31
main.go
View File

@ -42,19 +42,27 @@ func run(ctx context.Context) error {
return fmt.Errorf("build config service: %w", err) return fmt.Errorf("build config service: %w", err)
} }
help := flag.Bool("h", false, "Show help")
flag.Parse() flag.Parse()
if *help {
printUsage()
return nil
}
if narg := flag.NArg(); narg > 1 { if narg := flag.NArg(); narg > 1 {
flag.Usage() printUsage()
return fmt.Errorf("too many arguments") return fmt.Errorf("too many arguments")
} else if narg == 1 { } else if narg == 1 {
switch flag.Arg(0) { switch flag.Arg(0) {
case "edit-config": case "edit-config":
return editConfigFile(configService.Path()) return editConfigFile(configService)
case "print-config": case "print-config":
return printConfigPath(configService.Path()) return printConfigPath(configService.Path())
case "version": case "version":
return printVersion() return printVersion()
case "help", "-h", "--help": case "help":
printUsage() printUsage()
return nil return nil
} }
@ -76,7 +84,10 @@ func run(ctx context.Context) error {
clipboardAvailable = true clipboardAvailable = true
} }
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) dockerClient, err := dockerclient.NewClientWithOpts(
dockerclient.FromEnv,
dockerclient.WithAPIVersionNegotiation(),
)
if err != nil { if err != nil {
return fmt.Errorf("new docker client: %w", err) return fmt.Errorf("new docker client: %w", err)
} }
@ -105,7 +116,11 @@ func run(ctx context.Context) error {
} }
// editConfigFile opens the config file in the user's editor. // editConfigFile opens the config file in the user's editor.
func editConfigFile(configPath string) error { func editConfigFile(configService *config.Service) error {
if _, err := configService.ReadOrCreateConfig(); err != nil {
return fmt.Errorf("read or create config: %w", err)
}
editor := os.Getenv("EDITOR") editor := os.Getenv("EDITOR")
if editor == "" { if editor == "" {
editor = "vi" editor = "vi"
@ -115,10 +130,10 @@ func editConfigFile(configPath string) error {
return fmt.Errorf("look path: %w", err) return fmt.Errorf("look path: %w", err)
} }
fmt.Fprintf(os.Stderr, "Editing config file: %s\n", configPath) fmt.Fprintf(os.Stderr, "Editing config file: %s\n", configService.Path())
fmt.Println(binary) fmt.Println(binary)
if err := syscall.Exec(binary, []string{"--", configPath}, os.Environ()); err != nil { if err := syscall.Exec(binary, []string{"--", configService.Path()}, os.Environ()); err != nil {
return fmt.Errorf("exec: %w", err) return fmt.Errorf("exec: %w", err)
} }
@ -155,7 +170,7 @@ func buildLogger(cfg config.LogFile) (*slog.Logger, error) {
return slog.New(slog.DiscardHandler), nil return slog.New(slog.DiscardHandler), nil
} }
fptr, err := os.OpenFile(cfg.Path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) fptr, err := os.OpenFile(cfg.GetPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil { if err != nil {
return nil, fmt.Errorf("error opening log file: %w", err) return nil, fmt.Errorf("error opening log file: %w", err)
} }

View File

@ -1,5 +1,5 @@
[env] [env]
GOTOOLCHAIN = "go1.24.1" GOTOOLCHAIN = "go1.24.2"
[tasks.test] [tasks.test]
description = "Run tests" description = "Run tests"
@ -10,7 +10,7 @@ alias = "t"
[tasks.test_integration] [tasks.test_integration]
description = "Run integration tests" description = "Run integration tests"
dir = "{{cwd}}" dir = "{{cwd}}"
run = "go test -v -count 1 -parallel 1 -tags=integration -run TestIntegration ./..." run = "go test -v -count 1 -p 1 -tags=integration -run TestIntegration ./..."
alias = "ti" alias = "ti"
[tasks.test_ci] [tasks.test_ci]
@ -21,7 +21,7 @@ run = "go test -v -count 1 -race ./..."
[tasks.test_integration_ci] [tasks.test_integration_ci]
description = "Run integration tests in CI" description = "Run integration tests in CI"
dir = "{{cwd}}" dir = "{{cwd}}"
run = "go test -v -count 1 -race -parallel 1 -tags=integration -run TestIntegration ./..." run = "go test -v -count 1 -race -p 1 -tags=integration -run TestIntegration ./..."
[tasks.lint] [tasks.lint]
description = "Run linters" description = "Run linters"
@ -29,6 +29,12 @@ dir = "{{cwd}}"
run = "golangci-lint run" run = "golangci-lint run"
alias = "l" alias = "l"
[tasks.fmt]
description = "Run formatter"
dir = "{{cwd}}"
run = "goimports -w ."
alias = "f"
[tasks.generate_mocks] [tasks.generate_mocks]
description = "Generate mocks" description = "Generate mocks"
dir = "{{cwd}}" dir = "{{cwd}}"

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
#MISE description="Build and push mediamtx image"
set -euo pipefail
docker build -f build/mediamtx.Dockerfile -t netfluxio/mediamtx-alpine:latest .
docker push netfluxio/mediamtx-alpine:latest