Compare commits
57 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
add511e3dd | ||
|
7afa84505e | ||
|
4a863a3212 | ||
|
98d93ad286 | ||
|
5f026be769 | ||
|
d35dedb15b | ||
|
e49bbb6800 | ||
|
c022c18a7f | ||
|
b147da6d9b | ||
|
e113d55044 | ||
|
0df42511ce | ||
|
f853a5cced | ||
|
781d535d38 | ||
|
f3a5b802b8 | ||
|
6646402273 | ||
|
f6c87c4568 | ||
|
2f980acbb3 | ||
|
b8389eceb0 | ||
|
f2b9e9af75 | ||
|
8eca4b0e27 | ||
|
b05ae25809 | ||
|
52b0616d5f | ||
|
55e04e0249 | ||
|
df9724afa7 | ||
|
579dfeef22 | ||
|
5f6b6fa3a9 | ||
|
c4bc32b3e8 | ||
|
f7f9843c4b | ||
|
b69e1ecf86 | ||
|
524dafd800 | ||
|
b257f456ba | ||
|
045498a2ce | ||
|
08a5690d30 | ||
|
d332a78af1 | ||
|
f791125c02 | ||
|
30da888184 | ||
|
2fbf2176cf | ||
|
af4410c4cf | ||
|
18d5ef3db1 | ||
|
1e759b6f42 | ||
|
3523a1a34e | ||
|
5735b2d73a | ||
|
0fbbe25e1b | ||
|
1c742426ed | ||
|
e778c3c443 | ||
|
e14cfdee85 | ||
|
266a9307d2 | ||
|
cd2c339c10 | ||
|
54cfe3a55f | ||
|
87f6786387 | ||
|
2eeb809e6e | ||
|
522be621ee | ||
|
6952516204 | ||
|
ba356137c3 | ||
|
b05bc6f98b | ||
|
34f18b0fbd | ||
|
5bf19f92b0 |
@ -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
89
.github/workflows/codeql.yml
vendored
Normal 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}}"
|
@ -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
43
CONTRIBUTING.md
Normal 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
153
README.md
@ -1,6 +1,153 @@
|
|||||||
# Octoplex :octopus:
|
# Octoplex :octopus:
|
||||||
|
|
||||||
Octoplex multiplexes RTMP streams to multiple destinations from the
|

|
||||||
comfort of your terminal.
|

|
||||||
|

|
||||||
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||

|
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:
|
||||||
|
|
||||||
|
[](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`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
11
SECURITY.md
Normal 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
BIN
assets/obs1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
@ -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
23
go.mod
@ -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
48
go.sum
@ -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=
|
||||||
|
@ -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)}
|
||||||
|
}
|
||||||
|
204
internal/app/integration_helpers_test.go
Normal file
204
internal/app/integration_helpers_test.go
Normal 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
12
internal/app/testdata/mediamtx.yml
vendored
Normal 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
17
internal/app/testdata/openssl.cnf
vendored
Normal 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
18
internal/app/testdata/server.crt
vendored
Normal 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
28
internal/app/testdata/server.key
vendored
Normal 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-----
|
@ -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.
|
||||||
|
@ -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
|
|
@ -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]++
|
||||||
|
@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
internal/config/testdata/complete.yml
vendored
17
internal/config/testdata/complete.yml
vendored
@ -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
|
||||||
|
@ -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/%%+
|
9
internal/config/testdata/rtmps-only.yml
vendored
Normal file
9
internal/config/testdata/rtmps-only.yml
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
logfile:
|
||||||
|
enabled: true
|
||||||
|
sources:
|
||||||
|
mediaServer:
|
||||||
|
rtmps:
|
||||||
|
enabled: true
|
||||||
|
ip: 0.0.0.0
|
||||||
|
port: 1935
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
53
internal/container/logs.go
Normal file
53
internal/container/logs.go
Normal 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:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
internal/container/logs_test.go
Normal file
45
internal/container/logs_test.go
Normal 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)
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package terminal
|
package domain
|
||||||
|
|
||||||
// CommandAddDestination adds a destination.
|
// CommandAddDestination adds a destination.
|
||||||
type CommandAddDestination struct {
|
type CommandAddDestination struct {
|
@ -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.
|
||||||
|
@ -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())
|
||||||
|
}
|
||||||
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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"`
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
51
internal/replicator/replicator_test.go
Normal file
51
internal/replicator/replicator_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
31
main.go
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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}}"
|
||||||
|
@ -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
|
|
Loading…
x
Reference in New Issue
Block a user