// This is a jsonnet file, it generates kubernetes manifests. // To generate and apply, run "jsonnet k8s.jsonnet | kubectl apply -f -" // Note that this file is currently not as advanced as its docker-compose variant // This file can only be used for replication nodes and editing nodes // see config.enabled for more info on what components can be used { kind: "List", apiVersion: "v1", config:: { // These are the important top-level settings. // Change these to configure the services. // Image tag (application version) to use. // Note: "latest" is not recommended in production, as you can't be sure what version // you're actually running, and must manually re-pull to get an updated copy. image_tag: "latest", image_base: "ghcr.io/dbvideostriketeam", // Change this to use images from a different source than the main one // image tag for postgres, which changes less // postgres shouldn't be restarted unless absolutely necessary database_tag: "bb05e37", // For each component, whether to deploy that component. enabled: { downloader: true, # fetching segments from twitch.tv restreamer: true, # serving segments for other wubloader nodes and/or thrimbletrimmer editor interface backfiller: true, # fetching segments from other wubloader nodes cutter: false, # performing cuts based on editor input sheetsync: false, # syncing google sheets and postgres thrimshim: false, # storing editor inputs in postgres segment_coverage: true, # generating segment coverage graphs playlist_manager: false, # auto-populating youtube playlists nginx: true, # proxying between the various pods postgres: false, # source-of-truth database chat_archiver: false, # records twitch chat messages and merges them with records from other nodes }, // Twitch channels to capture. // Channels suffixed with a '!' are considered "important" and will be retried more aggressively // and warned about if they're not currently streaming. channels: ["desertbus!", "db_chief", "db_high", "db_audio", "db_bus"], backfill_only_channels: [], // extra directories to backfill backfill_dirs: [], // Cleaned up version of $.channels without importance/type markers. // General form is CHANNEL[!][:TYPE:URL]. clean_channels: [std.split(std.split(c, ":")[0], '!')[0] for c in $.config.channels] + $.config.backfill_only_channels, // Stream qualities to capture qualities: ["source", "480p"], // NFS settings for RWX (ReadWriteMany) volume for wubloader pods nfs_server: "nfs.example.com", # server IP or hostname nfs_path: "/mnt/segments", # path on server to mount nfs_capacity: "1T", # storage capacity to report to k8s nfs_mount_options: ["noatime"], # mount options to use (important for performance!) // PVC template storage class for statefulset in postgres sts_storage_class_name: "longhorn", // Other nodes to always backfill from. You should not include the local node. // If you are using the database to find peers, you should leave this empty. peers: [ ], // This node's name in the nodes table of the database localhost: "node_name", // The hostname to use in the Ingress ingress_host: "wubloader.example.com", // Set to true to let the ingress handle TLS ingress_tls: true, // Ingress class for ingress ingress_class_name: "nginx", // Uncomment and give a secretName for ingress, if required for ingress TLS //ingress_secret_name: "wubloader-tls", // Additional metadata labels for Ingress (cert-manager, etc.) - adjust as needed for your setup ingress_labels: {}, // Connection args for the database. // If database is defined in this config, host and port should be wubloader-postgres:5432. db_args: { user: "vst", password: "dbfh2019", // don't use default in production. Must not contain ' or \ as these are not escaped. host: "postgres", port: 5432, dbname: "wubloader", }, // Other database arguments db_super_user: "postgres", // only accessible from localhost db_super_password: "postgres", // Must not contain ' or \ as these are not escaped. db_replication_user: "replicate", // if empty, don't allow replication db_replication_password: "standby", // don't use default in production. Must not contain ' or \ as these are not escaped. db_readonly_user: "vst-ro", // if empty, don't have a readonly account db_readonly_password: "volunteer", // don't use default in production. Must not contain ' or \ as these are not escaped. db_standby: false, // set to true to have this database replicate another server // path to a JSON file containing google credentials for cutter as keys // 'client_id', 'client_secret', and 'refresh_token'. cutter_creds: import "./google_creds.json", // Path to a JSON file containing google credentials for sheetsync as keys // 'client_id', 'client_secret' and 'refresh_token'. // May be the same as cutter_creds_file. sheetsync_creds: import "./google_creds.json", // Path to a file containing a twitch OAuth token to use when downloading streams. // This is optional (null to omit) but may be helpful to bypass ads. downloader_creds_file: null, // The URL to write to the sheet for edit links, with {} being replaced by the id edit_url: "https://wubloader.example.com/edit.html?id={}", // The spreadsheet ID and worksheet names for sheetsync to act on sheet_id: "your_id_here", worksheets: ["Tech Test & Preshow"] + ["Day %d" % n for n in std.range(1, 8)], playlist_worksheet: "Tags", // The archive worksheet, if given, points to a worksheet containing events with a different // schema and alternate behaviour suitable for long-term archival videos instead of uploads. archive_worksheet: "Video Trim Times", // Fixed tags to add to all videos video_tags: ["DB17", "DB2023", "2023", "Desert Bus", "Desert Bus for Hope", "Child's Play Charity", "Child's Play", "Charity Fundraiser"], // The timestamp corresponding to 00:00 in bustime bustime_start: "1970-01-01T00:00:00Z", // The timestamps to start/end segment coverage maps at. // Generally 1 day before and 7 days after bus start. coverage_start: "1969-12-31T00:00:00Z", coverage_end: "1970-01-07T00:00:00Z", // Max hours ago to backfill, ie. do not backfill for times before this many hours ago. // Set to null to disable. backfill_max_hours_ago: 24 * 14, // approx 14 days // Extra options to pass via environment variables, // eg. log level, disabling stack sampling. env: { // Uncomment this to set log level to debug // WUBLOADER_LOG_LEVEL: "DEBUG", // Uncomment this to enable stacksampling performance monitoring // WUBLOADER_ENABLE_STACKSAMPLER: "true", }, // A map from youtube playlist IDs to a list of tags. // Playlist manager will populate each playlist with all videos which have all those tags. // For example, tags ["Day 1", "Technical"] will populate the playlist with all Technical // youtube videos from Day 1. // Note that you can make an "all videos" playlist by specifying no tags (ie. []). playlists: { // Replaced entirely by tags sheet }, // Which upload locations should be added to playlists youtube_upload_locations: [ "desertbus", "desertbus_slow", "desertbus_emergency", "youtube-manual", ], // Config for cutter upload locations. See cutter docs for full detail. cutter_config: { // Default desertbus: {type: "youtube", cut_type: "smart"}, // Backup options for advanced use, if the smart cut breaks things. desertbus_slow: {type: "youtube", cut_type: "full"}, desertbus_emergency: {type: "youtube", cut_type: "fast"}, }, default_location: "desertbus", // archive location is the default location for archive events, // only revelant if $.archive_worksheet is set. archive_location: "archive", // The header to put at the front of video titles, eg. a video with a title // of "hello world" with title header "foo" becomes: "foo - hello world". title_header: "DB2023", // The footer to put at the bottom of descriptions, in its own paragraph description_footer: "Uploaded by the Desert Bus Video Strike Team", // Chat archiver settings chat_archiver: { // Twitch user to log in as and path to oauth token user: "dbvideostriketeam", token: importstr "./chat_token.txt", // Whether to enable backfilling of chat archives to this node (if backfiller enabled) backfill: true, // Channels to watch. Defaults to "all twitch channels in $.channels" but you can add extras. channels: [ std.split(c, '!')[0] for c in $.channels if std.length(std.split(c, ":")) == 1 ], }, }, // A few derived values. // The connection string for the database. Constructed from db_args. db_connect:: std.join(" ", [ "%s='%s'" % [key, $.config.db_args[key]] for key in std.objectFields($.config.db_args) ]), // Cleaned up version of $.channels without importance markers clean_channels:: [std.split(c, '!')[0] for c in $.config.channels], // k8s-formatted version of env dict env_list:: [ {name: key, value: $.config.env[key]} for key in std.objectFields($.config.env) ], // Which upload locations have type youtube, needed for playlist_manager youtube_upload_locations:: [ location for location in std.objectFields($.config.cutter_config) if $.config.cutter_config[location].type == "youtube" ], // This function generates deployments for each service, since they only differ slightly, // with only a different image, CLI args and possibly env vars. // The image name is derived from the component name // (eg. "downloader" is ghcr.io/dbvideostriketeam/wubloader-downloader) // so we only pass in name as a required arg. // Optional kwargs work just like python. deployment(name, args=[], env=[], volumes=[], volumeMounts=[], resources={}):: { kind: "Deployment", apiVersion: "apps/v1", metadata: { namespace: "wubloader", name: name, labels: {app: "wubloader", component: name}, }, spec: { replicas: 1, selector: { matchLabels: {app: "wubloader", component: name}, }, template: { metadata: { labels: {app: "wubloader", component: name}, }, spec: { containers: [ { name: name, // segment-coverage is called segment_coverage in the image, so replace - with _ // ditto for playlist-manager image: "%s/wubloader-%s:%s" % [$.config.image_base, std.strReplace(name, "-", "_"), $.config.image_tag], args: args, resources: resources, volumeMounts: [{name: "data", mountPath: "/mnt"}] + volumeMounts, env: $.env_list + env, // main env list combined with any deployment-specific ones }, ], volumes: [ { name: "data", persistentVolumeClaim: {"claimName": "segments"}, }, ] + volumes }, }, }, }, // This function generates a Service object for each service service(name):: { kind: "Service", apiVersion: "v1", metadata: { namespace: "wubloader", name: name, labels: {app: "wubloader", component: name}, }, spec: { selector: {app: "wubloader", component: name}, ports: if name == "postgres" then [{name: "postgres", port: 5432, targetPort: 5432},] else [{name: "http", port: 80, targetPort: 80}], }, }, // This function generates a StatefulSet object (for postgres) statefulset(name, args=[], env=[]):: { kind: "StatefulSet", apiVersion: "apps/v1", metadata: { namespace: "wubloader", name: name, labels: {app: "wubloader", component: name}, }, spec: { replicas: 1, selector: { matchLabels: {app: "wubloader", component: name}, }, serviceName: name, template: { metadata: { labels: {app: "wubloader", component: name}, }, spec: { containers: [ { name: name, image: "%s/wubloader-%s:%s" % [$.config.image_base, name, $.config.database_tag], args: args, env: $.env_list + env, // main env list combined with any statefulset-specific ones volumeMounts: [ // tell use a subfolder in the newly provisioned PVC to store postgres DB // a newly provisioned ext4 PVC will be non-empty, so postgres fails to start if we don't use a subfolder {name: "database", mountPath: "/mnt/database", subPath: "postgres"}, {name: "segments", mountPath: "/mnt/wubloader"} ], }, ], volumes: [ { name: "segments", persistentVolumeClaim: {"claimName": "segments"}, }, ], }, }, volumeClaimTemplates: [ { metadata: { namespace: "wubloader", name: "database" }, spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "50GiB" }, }, storageClassName: $.config.sts_storage_class_name }, }, ], }, }, // The actual manifests to output, filtering out "null" from disabled components. items: [comp for comp in $.components if comp != null], // These are all the deployments and services. // Note that all components work fine if multiple are running // (they may duplicate work, but not cause errors by stepping on each others' toes). components:: [ // A namespace where all the things go { "apiVersion": "v1", "kind": "Namespace", "metadata": { "name": "wubloader" }, }, // The downloader watches the twitch stream and writes the HLS segments to disk if $.config.enabled.downloader then $.deployment("downloader", args=$.config.channels + [ "--base-dir", "/mnt", "--qualities", std.join(",", $.config.qualities), "--metrics-port", "80", ]+ if $.config.downloader_creds_file != null then ["--auth-file", "/etc/creds/downloader_token.txt"] else [], volumes=[ {name:"credentials", secret: {secretName: "credentials"}} ], volumeMounts=[ {mountPath: "/etc/creds", name: "credentials"}, ]), // The restreamer is a http server that fields requests for checking what segments exist // and allows HLS streaming of segments from any requested timestamp if $.config.enabled.restreamer then $.deployment("restreamer", args=[ "--base-dir", "/mnt", "--port", "80", ]), // The backfiller periodically compares what segments exist locally to what exists on // other nodes. If it finds ones it doesn't have, it downloads them. // It can talk to the database to discover other wubloader nodes, or be given a static list. if $.config.enabled.backfiller then $.deployment("backfiller", args=$.config.clean_channels + [ "--base-dir", "/mnt", "--qualities", std.join(",", $.config.qualities + (if $.config.chat_archiver.backfill then ["chat"] else [])), "--extras", std.join(",", $.config.backfill_dirs), "--static-nodes", std.join(",", $.config.peers), "--node-database", $.db_connect, "--localhost", $.config.localhost, "--metrics-port", "80", ] + (if $.config.backfill_max_hours_ago == null then [] else [ "--start", std.toString($.config.backfill_max_hours_ago), ])), // Segment coverage is a monitoring helper that periodically scans available segments // and reports stats. It also creates a "coverage map" image to represent this info. // It puts this in the segment directory where nginx will serve it. if $.config.enabled.segment_coverage then $.deployment("segment-coverage", args=$.config.clean_channels + [ "--base-dir", "/mnt", "--qualities", std.join(",", $.config.qualities), "--metrics-port", "80", "--first-hour", $.config.coverage_start, "--last-hour", $.config.coverage_end, // Render a html page showing all the images from all nodes "--make-page", "--connection-string", $.db_connect, ]), // Thrimshim acts as an interface between the thrimbletrimmer editor and the database // It is needed for thrimbletrimmer to be able to get unedited videos and submit edits if $.config.enabled.thrimshim then $.deployment("thrimshim", args=[ "--port", "80", "--title-header", $.config.title_header, "--description-footer", $.config.description_footer, "--upload-locations", std.join(",", [$.config.default_location] + [ location for location in std.objectFields($.config.cutter_config) if location != $.config.default_location ]), $.db_connect, $.config.clean_channels[0], // use first element as default channel $.config.bustime_start, ]), // Cutter interacts with the database to perform cutting jobs if $.config.enabled.cutter then $.deployment("cutter", args=[ "--base-dir", "/mnt", "--metrics-port", "80", "--name", $.config.localhost, "--tags", std.join(",", $.config.video_tags), $.db_connect, std.manifestJson($.config.cutter_config), "/etc/creds/cutter_creds.json" ], volumes=[ {name:"credentials", secret: {secretName: "credentials"}} ], volumeMounts=[ {mountPath: "/etc/creds", name: "credentials"}, ]), // Sheetsync syncs database columns to the google docs sheet which is the primary operator interface if $.config.enabled.sheetsync then $.deployment("sheetsync", args=[ "--allocate-ids", "--metrics-port", "80", $.config.db_connect, "/etc/creds/sheetsync_creds.json", $.config.edit_url, $.config.bustime_start, $.config.sheet_id ] + $.config.worksheets, volumes=[ {name:"credentials", secret: {secretName: "credentials"}} ], volumeMounts=[ {mountPath: "/etc/creds", name: "credentials"}, ]), // playlist_manager adds videos to youtube playlists depending on tags if $.config.enabled.playlist_manager then $.deployment("playlist-manager", args=[ "--metrics-port", "80", "--upload-location-allowlist", std.join(",", $.youtube_upload_locations), $.config.db_connect, "/etc/creds/cutter_creds.json" ] + [ "%s=%s" % [playlist, std.join(",", $.playlists[playlist])] for playlist in std.objectFields($.playlists) ], volumes=[ {name:"credentials", secret: {secretName: "credentials"}} ], volumeMounts=[ {mountPath: "/etc/creds", name: "credentials"}, ]), // chat_archiver records twitch chat messages and merges them with records from other nodes. if $.config.enabled.chat_archiver then $.deployment("chat-archiver", args=[ $.config.chat_archiver.user, "/etc/creds/chat_token.txt", ] + $.config.clean_channels + [ "--name", $.config.localhost, "--metrics-port", "80" ], volumes=[ {name:"credentials", secret: {secretName: "credentials"}} ], volumeMounts=[ {mountPath: "/etc/creds", name: "credentials"}, ]), // Normally nginx would be responsible for proxying requests to different services, // but in k8s we can use Ingress to do that. However nginx is still needed to serve // static content - segments as well as thrimbletrimmer. if $.config.enabled.nginx then $.deployment("nginx", env=[ {name: "THRIMBLETRIMMER", value: "true"}, {name: "SEGMENTS", value: "/mnt"}, ]), // postgres statefulset if $.config.enabled.postgres then $.statefulset("postgres", args=if $.config.db_standby then ["/standby_setup.sh"] else [], env=[ {name: "POSTGRES_USER", value: $.config.db_super_user}, {name: "POSTGRES_PASSWORD", value: $.config.db_super_password}, {name: "POSTGRES_DB", value: $.config.db_args.dbname}, {name: "PGDATA", value: "/mnt/database"}, {name: "WUBLOADER_USER", value: $.config.db_args.user}, {name: "WUBLOADER_PASSWORD", value: $.config.db_args.password}, {name: "REPLICATION_USER", value: $.config.db_replication_user}, {name: "REPLICATION_PASSWORD", value: $.config.db_replication_password}, {name: "READONLY_USER", value: $.config.db_readonly_user}, {name: "READONLY_PASSWORD", value: $.config.db_readonly_password}, {name: "MASTER_NODE", value: $.config.db_args.host}, ]), // Services for all deployments if $.config.enabled.downloader then $.service("downloader"), if $.config.enabled.backfiller then $.service("backfiller"), if $.config.enabled.nginx then $.service("nginx"), if $.config.enabled.restreamer then $.service("restreamer"), if $.config.enabled.segment_coverage then $.service("segment-coverage"), if $.config.enabled.thrimshim then $.service("thrimshim"), if $.config.enabled.cutter then $.service("cutter"), if $.config.enabled.playlist_manager then $.service("playlist-manager"), if $.config.enabled.sheetsync then $.service("sheetsync"), if $.config.enabled.postgres then $.service("postgres"), if $.config.enabled.chat_archiver then $.service("chat-archiver"), // Secret for credentials if $.config.enabled.cutter || $.config.enabled.sheetsync || $.config.enabled.playlist_manager || $.config.enabled.chat_archiver then { apiVersion: "v1", kind: "Secret", metadata: { namespace: "wubloader", name: "credentials", labels: {app: "wubloader"} }, type: "Opaque", stringData: { "cutter_creds.json": std.toString($.config.cutter_creds), "sheetsync_creds.json": std.toString($.config.sheetsync_creds), "chat_token.txt": $.config.chat_archiver.token, "downloader_token.txt": std.toString($.config.downloader_creds_file) }, }, // PV manifest for segments { apiVersion: "v1", kind: "PersistentVolume", metadata: { namespace: "wubloader", name: "segments", labels: {app: "wubloader"}, }, spec: { accessModes: ["ReadWriteMany"], capacity: { storage: $.config.nfs_capacity }, mountOptions: $.config.nfs_mount_options, nfs: { server: $.config.nfs_server, path: $.config.nfs_path, readOnly: false }, persistentVolumeReclaimPolicy: "Retain", volumeMode: "Filesystem" }, }, // PVC manifest for segments { apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { namespace: "wubloader", name: "segments", labels: {app: "wubloader"}, }, spec: { accessModes: ["ReadWriteMany"], resources: { requests: { storage: $.config.nfs_capacity }, }, storageClassName: "", volumeName: "segments" }, }, // Ingress to direct requests to the correct services. { kind: "Ingress", apiVersion: "networking.k8s.io/v1", metadata: { namespace: "wubloader", name: "wubloader", labels: {app: "wubloader"} + $.config.ingress_labels, }, spec: { ingressClassName: $.config.ingress_class_name, rules: [ { host: $.config.ingress_host, http: { // Helper functions for defining the path rules below local rule(name, path, type) = { path: path, pathType: type, backend: { service: { name: std.strReplace(name, "_", "-"), port: { number: 80 }, }, }, }, local metric_rule(name) = rule(name, "/metrics/%s" % name, "Exact"), paths: [ // Map /metrics/NAME to each service metric_rule("downloader"), metric_rule("backfiller"), metric_rule("restreamer"), metric_rule("segment_coverage"), metric_rule("thrimshim"), metric_rule("cutter"), metric_rule("sheetsync"), metric_rule("playlist_manager"), metric_rule("chat_archiver"), // Map /segments and /thrimbletrimmer to the static content nginx rule("nginx", "/segments", "Prefix"), rule("nginx", "/thrimbletrimmer", "Prefix"), // Map /thrimshim to the thrimshim service rule("thrimshim", "/thrimshim", "Prefix"), // Map everything else to restreamer rule("restreamer", "/", "Prefix"), ], }, }, ], [if $.config.ingress_tls then 'tls']: [ { hosts: [ $.config.ingress_host, ], [if "ingress_secret_name" in $.config then 'secretName']: $.config.ingress_secret_name, }, ], }, }, ], }