castcaster 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/bin/castcaster +7 -0
  3. data/lib/castcaster/channel.rb +91 -0
  4. data/lib/castcaster/channel_cli.rb +105 -0
  5. data/lib/castcaster/cli.rb +104 -0
  6. data/lib/castcaster/config.rb +60 -0
  7. data/lib/castcaster/deploy/compose.rb +76 -0
  8. data/lib/castcaster/deploy/ffmpeg_services.rb +73 -0
  9. data/lib/castcaster/deploy/k8s.rb +91 -0
  10. data/lib/castcaster/deploy/swarm.rb +57 -0
  11. data/lib/castcaster/deploy/traefik.rb +80 -0
  12. data/lib/castcaster/deploy.rb +10 -0
  13. data/lib/castcaster/engines/base.rb +55 -0
  14. data/lib/castcaster/engines/nginx_rtmp.rb +23 -0
  15. data/lib/castcaster/engines.rb +17 -0
  16. data/lib/castcaster/ffmpeg_profiles.rb +16 -0
  17. data/lib/castcaster/version.rb +3 -0
  18. data/lib/castcaster.rb +17 -0
  19. data/templates/deploy/docker-compose.yml.erb +18 -0
  20. data/templates/deploy/docker-compose.yml.tera +16 -0
  21. data/templates/deploy/docker-stack.yml.erb +29 -0
  22. data/templates/deploy/docker-stack.yml.tera +26 -0
  23. data/templates/deploy/k8s/configmap.yaml.erb +6 -0
  24. data/templates/deploy/k8s/configmap.yaml.tera +6 -0
  25. data/templates/deploy/k8s/deployment.yaml.erb +77 -0
  26. data/templates/deploy/k8s/deployment.yaml.tera +77 -0
  27. data/templates/deploy/k8s/ingress.yaml.erb +35 -0
  28. data/templates/deploy/k8s/ingress.yaml.tera +35 -0
  29. data/templates/deploy/k8s/pvc.yaml.erb +14 -0
  30. data/templates/deploy/k8s/pvc.yaml.tera +14 -0
  31. data/templates/deploy/k8s/service.yaml.erb +37 -0
  32. data/templates/deploy/k8s/service.yaml.tera +37 -0
  33. data/templates/nginx-rtmp/nginx.conf.erb +110 -0
  34. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 473f74bfc9f9b8f427068f501fc5db2dc13071234084647028e13f487152dedd
4
+ data.tar.gz: 8566513c993848a0160a91f6c548c1c0dcd1cfdd9a162decb5df4c5e5a0a1122
5
+ SHA512:
6
+ metadata.gz: 7169e96f6ba961b134a96205f43bd023c6ed6a6a1287d57e73e3e78b34fabbb011f19059beb1b27550611a87ac901b51b014a2ef51fdfce99f6d5fd544c21171
7
+ data.tar.gz: a777f05ee638aa1cb76c74edfcf76c1d8d0aa7b1f8cb405ccd4cfe6c34294c41949144ae47b581d26194b31e02759db0b8a180ac4e99bb8e0da211f49d7dd742
data/bin/castcaster ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ ENV['THOR_SILENCE_DEPRECATION'] = '1'
4
+
5
+ require_relative '../lib/castcaster'
6
+
7
+ CastCaster::CLI.start(ARGV)
@@ -0,0 +1,91 @@
1
+ module CastCaster
2
+ class Channel
3
+ STATUSES = %w[stopped live error].freeze
4
+
5
+ attr_reader :name, :path
6
+ attr_accessor :engine, :source, :hls, :transcode, :snapshot, :status
7
+
8
+ def initialize(name, attrs = {})
9
+ @name = name
10
+ @engine = attrs['engine'] || 'default'
11
+ @source = attrs['source'] || {}
12
+ @hls = attrs['hls'] || { 'fragment' => 6, 'playlist_length' => 60 }
13
+ @transcode = attrs['transcode']
14
+ @snapshot = attrs['snapshot']
15
+ @status = attrs['status'] || 'stopped'
16
+ @path = File.join(self.class.channels_dir, "#{name}.yml")
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ 'name' => @name,
22
+ 'engine' => @engine,
23
+ 'source' => @source,
24
+ 'hls' => @hls,
25
+ 'transcode' => @transcode,
26
+ 'snapshot' => @snapshot,
27
+ 'status' => @status
28
+ }.compact
29
+ end
30
+
31
+ def save
32
+ File.write(@path, YAML.dump(to_h))
33
+ end
34
+
35
+ def destroy
36
+ File.delete(@path) if File.exist?(@path)
37
+ end
38
+
39
+ def live?
40
+ @status == 'live'
41
+ end
42
+
43
+ def validate!
44
+ raise Error, "Channel name required" if @name.nil? || @name.empty?
45
+ raise Error, "Invalid channel name: #{@name}" unless @name.match?(/\A[a-zA-Z0-9_-]+\z/)
46
+ end
47
+
48
+ class << self
49
+ def channels_dir
50
+ File.join(Dir.pwd, 'channels')
51
+ end
52
+
53
+ def all
54
+ Dir[File.join(channels_dir, '*.yml')].map do |f|
55
+ from_file(f)
56
+ end.compact.sort_by(&:name)
57
+ end
58
+
59
+ def find(name)
60
+ path = File.join(channels_dir, "#{name}.yml")
61
+ return nil unless File.exist?(path)
62
+ from_file(path)
63
+ end
64
+
65
+ def create(name, attrs = {})
66
+ ch = new(name, attrs)
67
+ ch.validate!
68
+ raise Error, "Channel '#{name}' already exists" if File.exist?(ch.path)
69
+ ch.save
70
+ ch
71
+ end
72
+
73
+ private
74
+
75
+ def from_file(path)
76
+ data = YAML.safe_load(File.read(path))
77
+ unless data
78
+ warn "Warning: failed to parse channel file: #{path}"
79
+ return nil
80
+ end
81
+ ch = new(data['name'], data)
82
+ ch.path = path
83
+ ch
84
+ end
85
+ end
86
+
87
+ def path=(p)
88
+ @path = p
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,105 @@
1
+ module CastCaster
2
+ class ChannelCLI < Thor
3
+ desc 'list', 'List all channels'
4
+ def list
5
+ channels = Channel.all
6
+ if channels.empty?
7
+ say 'No channels. Use `castcaster channel add --name NAME` to create one.'
8
+ return
9
+ end
10
+
11
+ shell = Thor::Shell::Basic.new
12
+ rows = channels.map do |ch|
13
+ [ch.name, ch.engine, ch.live? ? 'live' : 'stopped', ch.source.fetch('url', '-')]
14
+ end
15
+ shell.print_table(rows, headings: %w[Name Engine Status Source])
16
+ end
17
+
18
+ desc 'add', 'Add a new channel'
19
+ option :name, type: :string, required: true, desc: 'Channel name (a-z, 0-9, -, _)'
20
+ option :engine, type: :string, desc: 'Engine override'
21
+ option :source, type: :string, desc: 'Stream source URL'
22
+ option :source_type, type: :string, default: 'test', desc: 'Source type (test / rtmp_pull / rtmp_push / http_pull / srt)'
23
+ option :transcode, type: :string, desc: 'Transcode profiles (e.g. 720p,480p,360p)'
24
+ option :bitrate, type: :string, desc: 'Target bitrate (e.g. 1500k)'
25
+ option :snapshot, type: :numeric, desc: 'Snapshot interval in seconds'
26
+ def add
27
+ attrs = {
28
+ 'engine' => options[:engine],
29
+ 'source' => { 'type' => options[:source_type], 'url' => options[:source] }.compact,
30
+ 'status' => 'stopped'
31
+ }
32
+ attrs['transcode'] = { 'profiles' => options[:transcode], 'bitrate' => options[:bitrate] }.compact if options[:transcode] || options[:bitrate]
33
+ attrs['snapshot'] = options[:snapshot] if options[:snapshot]
34
+ Channel.create(options[:name], attrs)
35
+ say_status :ok, "channel '#{options[:name]}' created"
36
+ say_status :info, "Run `docker compose restart nginx` to apply"
37
+ rescue Error => e
38
+ say_status :error, e.message
39
+ exit 1
40
+ end
41
+
42
+ desc 'rm NAME', 'Remove a channel'
43
+ def rm(name)
44
+ ch = Channel.find(name)
45
+ unless ch
46
+ say_status :error, "channel '#{name}' not found"
47
+ exit 1
48
+ end
49
+ ch.destroy
50
+ say_status :ok, "channel '#{name}' removed"
51
+ say_status :info, "Run `docker compose restart nginx` to apply"
52
+ end
53
+
54
+ desc 'update NAME', 'Update channel configuration'
55
+ option :source_type, type: :string, desc: 'Source type (test / rtmp_pull / rtmp_push / http_pull / srt)'
56
+ option :source, type: :string, desc: 'Stream source URL'
57
+ option :transcode, type: :string, desc: 'Transcode profiles (e.g. 720p,480p,360p)'
58
+ option :bitrate, type: :string, desc: 'Target bitrate (e.g. 1500k)'
59
+ option :snapshot, type: :numeric, desc: 'Snapshot interval in seconds (0 to disable)'
60
+ def update(name)
61
+ ch = Channel.find(name)
62
+ unless ch
63
+ say_status :error, "channel '#{name}' not found"
64
+ exit 1
65
+ end
66
+ re_read = YAML.safe_load(File.read(ch.path)) || {}
67
+ re_read['source'] ||= {}
68
+ re_read['source']['type'] = options[:source_type] if options[:source_type]
69
+ re_read['source']['url'] = options[:source] if options[:source]
70
+ if options[:transcode] || options[:bitrate]
71
+ re_read['transcode'] = { 'profiles' => options[:transcode], 'bitrate' => options[:bitrate] }.compact
72
+ end
73
+ if options.key?(:snapshot)
74
+ re_read['snapshot'] = options[:snapshot] > 0 ? options[:snapshot] : nil
75
+ end
76
+ File.write(ch.path, YAML.dump(re_read))
77
+ say_status :ok, "channel '#{name}' updated"
78
+ say_status :info, "Run `docker compose restart nginx` to apply"
79
+ end
80
+
81
+ desc 'start NAME', 'Mark a channel as live'
82
+ def start(name)
83
+ ch = Channel.find(name)
84
+ unless ch
85
+ say_status :error, "channel '#{name}' not found"
86
+ exit 1
87
+ end
88
+ ch.status = 'live'
89
+ ch.save
90
+ say_status :ok, "channel '#{name}' marked live"
91
+ end
92
+
93
+ desc 'stop NAME', 'Mark a channel as stopped'
94
+ def stop(name)
95
+ ch = Channel.find(name)
96
+ unless ch
97
+ say_status :error, "channel '#{name}' not found"
98
+ exit 1
99
+ end
100
+ ch.status = 'stopped'
101
+ ch.save
102
+ say_status :ok, "channel '#{name}' marked stopped"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,104 @@
1
+ require 'thor'
2
+
3
+ module CastCaster
4
+ class CLI < Thor
5
+ package_name 'castcaster'
6
+
7
+ map %w[--version -v] => :version
8
+
9
+ desc 'init', 'Generate CastCaster config and project structure'
10
+ option :engine, type: :string, aliases: '-e', desc: 'Streaming engine (nginx-rtmp)'
11
+ def init
12
+ config = Config.new
13
+ config.ensure_project_dirs
14
+ token = config.ensure_token
15
+
16
+ cfg = Config::DEFAULTS.dup
17
+ cfg['engine'] = options[:engine] if options[:engine]
18
+ cfg['project_dir'] = Dir.pwd
19
+ config.save(cfg)
20
+
21
+ say_status :created, "castcaster.yml"
22
+ say_status :created, "castcaster.token"
23
+ say_status :created, "channels/"
24
+ say_status :created, "hls/"
25
+ say ''
26
+ say " CastCaster v#{VERSION} initialized"
27
+ say " Engine: #{cfg['engine']}"
28
+ say ''
29
+ say " Edit channels/, then run:"
30
+ say " docker compose up -d"
31
+ end
32
+
33
+ desc 'config', 'Preview generated nginx config'
34
+ option :engine, type: :string, aliases: '-e', desc: 'Override engine'
35
+ def config
36
+ cfg = Config.new.load
37
+ engine_name = options[:engine] || cfg['engine']
38
+ engine = Engines.create(engine_name, cfg)
39
+ say engine.config_preview
40
+ end
41
+
42
+ desc 'deploy', 'Generate compose.yml'
43
+ option :engine, type: :string, aliases: '-e', desc: 'Override engine'
44
+ option :mode, type: :string, aliases: '-m', default: 'compose', desc: 'compose | swarm | k8s'
45
+ option :with_traefik, type: :boolean, desc: 'Include Traefik'
46
+ def deploy
47
+ cfg = Config.new.load
48
+ cfg['enable_traefik'] = true if options[:with_traefik]
49
+ engine = create_engine
50
+ channels = Channel.all
51
+ case options[:mode]
52
+ when 'swarm'
53
+ dpl = Deploy::Swarm.new(engine, channels, cfg)
54
+ when 'k8s'
55
+ dpl = Deploy::K8s.new(engine, channels, cfg)
56
+ else
57
+ dpl = Deploy::Compose.new(engine, channels)
58
+ dpl.with_traefik = true if options[:with_traefik]
59
+ end
60
+ path = dpl.write
61
+ say_status :created, path
62
+ end
63
+
64
+ IMAGES = {
65
+ 'nginx' => 'ghcr.io/lax/castcaster-nginx',
66
+ 'ffmpeg' => 'ghcr.io/lax/castcaster-ffmpeg',
67
+ 'webui' => 'ghcr.io/lax/castcaster-webui'
68
+ }.freeze
69
+
70
+ desc 'doctor', 'Check system environment'
71
+ def doctor
72
+ checks = {
73
+ 'Ruby' => RUBY_VERSION,
74
+ 'Docker' => system('docker --version >/dev/null 2>&1') ? `docker --version`.strip : 'not found',
75
+ 'Docker Compose'=> system('docker compose version >/dev/null 2>&1') ? `docker compose version`.strip : 'not found',
76
+ 'config.yml' => File.exist?('castcaster.yml') ? 'ok' : 'not found (run init)'
77
+ }
78
+ IMAGES.each do |name, tag|
79
+ if system("docker image inspect #{tag} >/dev/null 2>&1")
80
+ checks[name] = 'available'
81
+ else
82
+ checks[name] = "docker pull #{tag}"
83
+ end
84
+ end
85
+ checks.each { |name, status| say "#{name.ljust(14)} #{status}" }
86
+ end
87
+
88
+ desc 'channel SUBCOMMAND ...', 'Manage channels'
89
+ subcommand 'channel', ChannelCLI
90
+
91
+ desc 'version', 'Show version'
92
+ def version
93
+ say VERSION
94
+ end
95
+
96
+ private
97
+
98
+ def create_engine
99
+ cfg = Config.new.load
100
+ engine_name = options[:engine] || cfg['engine']
101
+ Engines.create(engine_name, cfg)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,60 @@
1
+ module CastCaster
2
+ class Config
3
+ DEFAULTS = {
4
+ 'engine' => 'nginx-rtmp',
5
+ 'hls_fragment' => 6,
6
+ 'hls_window' => 60,
7
+ 'domain' => '',
8
+ 'acme_email' => 'admin@example.com',
9
+ 'enable_traefik' => false
10
+ }.freeze
11
+
12
+ def initialize(path = nil)
13
+ @path = path || default_config_path
14
+ end
15
+
16
+ def load
17
+ return DEFAULTS.dup unless File.exist?(@path)
18
+ cfg = YAML.safe_load(File.read(@path)) || {}
19
+ DEFAULTS.merge(cfg)
20
+ rescue => e
21
+ raise Error, "Config corrupted: #{e.message}"
22
+ end
23
+
24
+ def save(data)
25
+ FileUtils.mkdir_p(File.dirname(@path))
26
+ File.write(@path, YAML.dump(data))
27
+ end
28
+
29
+ def ensure_project_dirs
30
+ project_dir = Dir.pwd
31
+ FileUtils.mkdir_p(File.join(project_dir, 'channels'))
32
+ FileUtils.mkdir_p(File.join(project_dir, 'hls'))
33
+ FileUtils.mkdir_p(File.join(project_dir, 'nginx'))
34
+ FileUtils.mkdir_p(File.join(project_dir, 'logs'))
35
+ FileUtils.mkdir_p(File.join(project_dir, 'data'))
36
+ project_dir
37
+ end
38
+
39
+ def ensure_token
40
+ path = File.join(Dir.pwd, 'castcaster.token')
41
+ return File.read(path).strip if File.exist?(path)
42
+ token = SecureRandom.hex(32)
43
+ File.write(path, token)
44
+ File.chmod(0600, path)
45
+ token
46
+ end
47
+
48
+ def load_token
49
+ path = File.join(Dir.pwd, 'castcaster.token')
50
+ return nil unless File.exist?(path)
51
+ File.read(path).strip
52
+ end
53
+
54
+ private
55
+
56
+ def default_config_path
57
+ File.join(Dir.pwd, 'castcaster.yml')
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,76 @@
1
+ module CastCaster
2
+ module Deploy
3
+ class Compose
4
+ include FFmpegServices
5
+ attr_writer :with_traefik
6
+
7
+ def initialize(engine, channels)
8
+ @engine = engine
9
+ @channels = channels
10
+ @cfg = Config.new.load
11
+ @with_traefik = false
12
+ end
13
+
14
+ def write
15
+ File.write(compose_file, render)
16
+ compose_file
17
+ end
18
+
19
+ def compose_file
20
+ File.join(project_dir, 'compose.yml')
21
+ end
22
+
23
+ private
24
+
25
+ def project_dir
26
+ @cfg.fetch('project_dir', Dir.pwd)
27
+ end
28
+
29
+ def render
30
+ enable_traefik = @with_traefik || @cfg.fetch('enable_traefik', false)
31
+ nginx_ports = ['1935:1935']
32
+ nginx_ports << "#{@cfg.fetch('web_port', 8080)}:8080" unless enable_traefik
33
+
34
+ all = {
35
+ 'nginx' => {
36
+ 'image' => 'ghcr.io/lax/castcaster-nginx',
37
+ 'restart' => 'unless-stopped',
38
+ 'ports' => nginx_ports,
39
+ 'volumes' => [
40
+ "./nginx/nginx.conf:/etc/nginx/nginx.conf:ro",
41
+ "./hls:/var/lib/castcaster/hls"
42
+ ]
43
+ },
44
+ 'webui' => {
45
+ 'image' => 'ghcr.io/lax/castcaster-webui',
46
+ 'restart' => 'unless-stopped',
47
+ 'volumes' => ["./channels:/channels:rw", "./hls:/var/lib/castcaster/hls:ro"],
48
+ 'depends_on' => ['nginx'],
49
+ 'environment' => {
50
+ 'HLS_DIR' => '/var/lib/castcaster/hls',
51
+ 'CHANNELS_DIR' => '/channels'
52
+ }
53
+ }
54
+ }
55
+
56
+ # FFmpeg services (relative paths for host-side compose)
57
+ all.merge!(build_ffmpeg_services(@channels, './hls'))
58
+
59
+ if enable_traefik
60
+ traefik = Deploy::Traefik.new(@cfg)
61
+ all.merge!(traefik.service_definition)
62
+ FileUtils.mkdir_p(File.join(project_dir, 'traefik'))
63
+ File.write(File.join(project_dir, 'traefik', 'dynamic.yml'), traefik.send(:dynamic_config))
64
+ end
65
+
66
+ tpl = File.read(File.expand_path('../../../templates/deploy/docker-compose.yml.erb', __dir__))
67
+ erb = ERB.new(tpl, trim_mode: '-')
68
+ ctx = TemplateContext.new(services: all)
69
+ erb.result(ctx.get_binding)
70
+ end
71
+
72
+
73
+
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,73 @@
1
+ module CastCaster
2
+ module Deploy
3
+ module FFmpegServices
4
+ def build_ffmpeg_services(channels, hls_dir)
5
+ base = 'rtmp://nginx:1935/live'
6
+ services = {}
7
+ channels.each do |ch|
8
+ src = ch.source
9
+ next unless src && src['type']
10
+ next if src['type'] == 'rtmp_push'
11
+
12
+ name = ch.name
13
+ label = { 'castcaster/channel' => name }
14
+ common = {
15
+ 'image' => 'ghcr.io/lax/castcaster-ffmpeg',
16
+ 'restart' => 'unless-stopped',
17
+ 'volumes' => ["#{hls_dir}:/var/lib/castcaster/hls"],
18
+ 'depends_on' => ['nginx'],
19
+ 'labels' => label,
20
+ 'environment' => {
21
+ 'CHANNEL_NAME' => name,
22
+ 'RTMP_BASE' => base
23
+ }
24
+ }
25
+
26
+ case src['type']
27
+ when 'test'
28
+ services["castcaster-test-#{name}"] = common.merge(
29
+ 'command' => ["-c", "teststream.sh"],
30
+ 'labels' => label.merge('castcaster/task' => 'test')
31
+ )
32
+ else
33
+ next unless src['url']
34
+ bitrate = (ch.transcode || {}).fetch('bitrate', FFmpegProfiles::RELAY_DEFAULTS['bitrate'])
35
+ profiles = (ch.transcode || {}).fetch('profiles', nil)
36
+
37
+ if profiles
38
+ services["castcaster-adaptive-#{name}"] = common.merge(
39
+ 'environment' => common['environment'].merge(
40
+ 'SOURCE_URL' => src['url'],
41
+ 'PROFILES' => profiles
42
+ ),
43
+ 'command' => ["-c", "adaptive.sh"],
44
+ 'labels' => label.merge('castcaster/task' => 'adaptive')
45
+ )
46
+ else
47
+ services["castcaster-relay-#{name}"] = common.merge(
48
+ 'environment' => common['environment'].merge(
49
+ 'SOURCE_URL' => src['url'],
50
+ 'BITRATE' => bitrate
51
+ ),
52
+ 'command' => ["-c", "relay.sh"],
53
+ 'labels' => label.merge('castcaster/task' => 'relay')
54
+ )
55
+ end
56
+
57
+ if ch.snapshot
58
+ services["castcaster-snapshot-#{name}"] = common.merge(
59
+ 'environment' => common['environment'].merge(
60
+ 'INTERVAL' => ch.snapshot.to_s,
61
+ 'SNAPSHOTS_DIR' => '/var/lib/castcaster/snapshots'
62
+ ),
63
+ 'command' => ["-c", "snapshot.sh"],
64
+ 'labels' => label.merge('castcaster/task' => 'snapshot')
65
+ )
66
+ end
67
+ end
68
+ end
69
+ services
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,91 @@
1
+ module CastCaster
2
+ module Deploy
3
+ class K8s
4
+ def initialize(engine, channels, cfg)
5
+ @engine = engine
6
+ @channels = channels
7
+ @cfg = cfg
8
+ @traefik = Deploy::Traefik.new(@cfg) if @cfg.fetch('enable_traefik', false)
9
+ end
10
+
11
+ def write
12
+ FileUtils.mkdir_p(deploy_dir)
13
+ write_file('deployment.yaml', render_deployment)
14
+ write_file('service.yaml', render_service)
15
+ write_file('configmap.yaml', render_configmap)
16
+ write_file('pvc.yaml', render_pvc)
17
+ write_file('ingress.yaml', render_ingress) if @traefik
18
+ deploy_dir
19
+ end
20
+
21
+ def deploy_dir
22
+ File.join(@cfg.fetch('project_dir', Dir.pwd), 'k8s')
23
+ end
24
+
25
+ private
26
+
27
+ def write_file(name, content)
28
+ path = File.join(deploy_dir, name)
29
+ File.write(path, content)
30
+ end
31
+
32
+ def render_deployment
33
+ tpl = File.read(File.expand_path('../../../templates/deploy/k8s/deployment.yaml.erb', __dir__))
34
+ erb = ERB.new(tpl, trim_mode: '-')
35
+ ctx = TemplateContext.new(
36
+ app: 'castcaster',
37
+ engine_image: 'ghcr.io/lax/castcaster-nginx',
38
+ webui_image: 'ghcr.io/lax/castcaster-webui',
39
+ ffmpeg_image: 'ghcr.io/lax/castcaster-ffmpeg',
40
+ engine_name: @engine.name,
41
+ channels_dir: File.join(@cfg.fetch('project_dir', Dir.pwd), 'channels'),
42
+ hls_dir: @cfg.fetch('hls_dir', '/var/lib/castcaster/hls'),
43
+ web_port: @cfg.fetch('web_port', 9527),
44
+ webui_port: @cfg.fetch('webui_port', 9528)
45
+ )
46
+ erb.result(ctx.get_binding)
47
+ end
48
+
49
+ def render_service
50
+ tpl = File.read(File.expand_path('../../../templates/deploy/k8s/service.yaml.erb', __dir__))
51
+ erb = ERB.new(tpl, trim_mode: '-')
52
+ ctx = TemplateContext.new(
53
+ app: 'castcaster',
54
+ web_port: @cfg.fetch('web_port', 9527),
55
+ webui_port: @cfg.fetch('webui_port', 9528)
56
+ )
57
+ erb.result(ctx.get_binding)
58
+ end
59
+
60
+ def render_configmap
61
+ tpl = File.read(File.expand_path('../../../templates/deploy/k8s/configmap.yaml.erb', __dir__))
62
+ erb = ERB.new(tpl, trim_mode: '-')
63
+ ctx = TemplateContext.new(
64
+ app: 'castcaster',
65
+ nginx_conf_path: File.join(@cfg.fetch('project_dir', Dir.pwd), 'nginx', 'nginx.conf'),
66
+ channels_dir: File.join(@cfg.fetch('project_dir', Dir.pwd), 'channels')
67
+ )
68
+ erb.result(ctx.get_binding)
69
+ end
70
+
71
+ def render_pvc
72
+ tpl = File.read(File.expand_path('../../../templates/deploy/k8s/pvc.yaml.erb', __dir__))
73
+ erb = ERB.new(tpl, trim_mode: '-')
74
+ ctx = TemplateContext.new(app: 'castcaster')
75
+ erb.result(ctx.get_binding)
76
+ end
77
+
78
+ def render_ingress
79
+ domain = @cfg.fetch('domain', 'stream.example.com')
80
+ tpl = File.read(File.expand_path('../../../templates/deploy/k8s/ingress.yaml.erb', __dir__))
81
+ erb = ERB.new(tpl, trim_mode: '-')
82
+ ctx = TemplateContext.new(
83
+ app: 'castcaster',
84
+ domain: domain,
85
+ acme_email: @cfg.fetch('acme_email', 'admin@example.com')
86
+ )
87
+ erb.result(ctx.get_binding)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,57 @@
1
+ module CastCaster
2
+ module Deploy
3
+ class Swarm
4
+ include FFmpegServices
5
+
6
+ def initialize(engine, channels, cfg)
7
+ @engine = engine
8
+ @channels = channels
9
+ @cfg = cfg
10
+ end
11
+
12
+ def write
13
+ FileUtils.mkdir_p(deploy_dir)
14
+ File.write(compose_file, render)
15
+ compose_file
16
+ end
17
+
18
+ def compose_file
19
+ File.join(deploy_dir, 'docker-stack.yml')
20
+ end
21
+
22
+ def deploy_dir
23
+ @cfg.fetch('project_dir', Dir.pwd)
24
+ end
25
+
26
+ private
27
+
28
+ def render
29
+ all = {
30
+ 'nginx' => {
31
+ 'image' => 'ghcr.io/lax/castcaster-nginx',
32
+ 'restart' => 'unless-stopped',
33
+ 'ports' => ['1935:1935', '8080:8080'],
34
+ 'volumes' => ["./nginx/nginx.conf:/etc/nginx/nginx.conf:ro", "./hls:/var/lib/castcaster/hls"]
35
+ },
36
+ 'webui' => {
37
+ 'image' => 'ghcr.io/lax/castcaster-webui',
38
+ 'restart' => 'unless-stopped',
39
+ 'volumes' => ["./channels:/channels:rw"],
40
+ 'environment' => {'HLS_DIR' => '/var/lib/castcaster/hls', 'CHANNELS_DIR' => '/channels'}
41
+ }
42
+ }
43
+
44
+ all.merge!(build_ffmpeg_services(@channels, './hls'))
45
+
46
+ tpl = File.read(File.expand_path('../../../templates/deploy/docker-stack.yml.erb', __dir__))
47
+ erb = ERB.new(tpl, trim_mode: '-')
48
+ ctx = TemplateContext.new(services: all, project: 'castcaster', traefik_public: @cfg.fetch('enable_traefik', false))
49
+ erb.result(ctx.get_binding)
50
+ end
51
+
52
+ def project_dir
53
+ @cfg.fetch('project_dir', Dir.pwd)
54
+ end
55
+ end
56
+ end
57
+ end