require 'shellwords' module Siren class Compose class Service < Struct.new(:compose, :name, :build, :command, :configs, :container_name, :credential_spec, :deploy, :dns, :dns_search, :entrypoint, :environment, :expose, :extra_hosts, :healthcheck, :image, :init, :labels, :logging, :networks, :pid, :ports, :secrets, :stop_grace_period, :stop_signal, :sysctls, :ulimits, :volumes, :domainname, :hostname, :ipc, :mac_address, :privileged, :read_only, :shm_size, :stdin_open, :tty, :user, :working_dir) def initialize (compose, data = {}) self.compose = compose data.each do |key, value| self.__send__("#{key}=", value) end self.deploy = deploy ? Deploy.new(deploy) : Deploy.new self.ports ||= [] self.volumes ||= [] self.name = namify(self.name) end def ports= (ports) ports = ports.map do |port| if port.is_a?(String) target, published, protocol = port.match(/^(\d+)?(?::(\d+))?(\/.+)?$/).to_a[1..-1] else target, published, protocol, mode = port.values_at("target", "published", "protocol", "mode") end protocol ||= "tcp" { "target" => target, "published" => published, "protocol" => protocol, "mode" => mode, } end compose.xdomains.each do |xd| next unless xd["service"] == name ports.unshift({ "protocol" => "tcp", "target" => xd["port"], "published" => "80", }) end compose.xports.each do |xp| next unless xp["service"] == name ports.unshift({ "protocol" => xp["protocol"], "target" => xp["inside"], "published" => xp["outside"], "mode" => "ingress", }) end ports.uniq!{|p|p.values_at("published", "protocol")} super(ports) end def command= (value) value = ["sh", "-c", value] if value.is_a?(String) super(value) # if configs end def resolve_environment (env) if env.is_a?(Array) env = env.map do |line| key, value = line.split("=", 2) [key, value || "$#{key}"] end.to_h end env ||= {} env.transform_values! do |value| value.gsub(/\$[\dA-Z_a-z]+/) do |name| compose.xenv[name[1..-1]] end end env.map do |name, value| { name: name, value: value, } end end def namify (*items) items.flatten.compact.join("-").downcase.gsub(/[^a-z\d]/, '-').gsub(/-+/, "-") end def emit_deployment (stack) stack << { kind: "Deployment", apiVersion: "apps/v1", metadata: { namespace: compose.name, name: name, }, spec: { replicas: deploy.replicas, selector: { matchLabels: { stack: compose.name, service: name, }, }, template: { metadata: { labels: { stack: compose.name, service: name, }, }, spec: { containers: [ command: command, env: resolve_environment(environment), image: image, name: name, volumeMounts: volumes.map do |volume| name, path = volume.split(":", 2) { mountPath: path, name: name, } end ], hostname: hostname, imagePullSecrets: [{name: "image-pull-secrets"}], restartPolicy: { "none" => "Never", "on-failure" => "OnFailure" }[deploy.restart_policy&.condition], volumes: volumes.map do |volume| name = volume.split(":")[0] { name: name, persistentVolumeClaim: { claimName: name } } end }, }, }, } end def emit_services (stack) ports.group_by{|port|port["mode"]}.each do |mode, ports| stack << { kind: "Service", apiVersion: "v1", metadata: { namespace: compose.name, name: mode == "ingress" ? "#{name}-nodeport" : name, }, spec: { ports: ports.map do |port| { name: ports.length == 1 ? nil : port["published"], port: port["published"].to_i, targetPort: port["target"] != port["published"] ? port["target"].to_i : nil, protocol: port["protocol"] == "udp" ? "UDP" : nil } end, selector: { stack: compose.name, service: name, }, type: mode == "ingress" ? "NodePort" : nil, } } end end def emit_auth_middleware (stack) return if @did_emit_auth_middleware did_emit_auth_middleware = true stack << { kind: "Middleware", apiVersion: "traefik.containo.us/v1alpha1", metadata: { namespace: compose.name, name: "altaire-auth", }, spec: { forwardAuth: { address: "http://auth-server.default.svc.cluster.local/auth", }, }, } end def emit_middlewares (stack) compose.xdomains.each do |xd| next unless xd["service"] == name next if xd["path"] == nil stack << { kind: "Middleware", apiVersion: "traefik.containo.us/v1alpha1", metadata: { namespace: compose.name, name: namify(xd["name"], xd["path"], "strip-prefix"), }, spec: { stripPrefix: { prefixes: ["/#{xd["path"]}"], }, }, } end end def emit_ingress_routes (stack) compose.xdomains.each do |xd| next unless xd["service"] == name emit_auth_middleware stack if xd["auth"] stack << { kind: "IngressRoute", apiVersion: "traefik.containo.us/v1alpha1", metadata: { namespace: compose.name, name: namify(xd["name"], xd["path"]), }, spec: { tls: { secretName: namify(xd["name"]), }, routes: [{ kind: "Rule", match: nil, middlewares: [ xd["auth"] ? { name: "altaire-auth" } : nil, xd["path"] ? { name: namify(xd["name"], xd["path"], "strip-prefix") } : nil, ], services: [{ name: name, port: 80 }], }.merge( if xd["path"] {match: "Host(`#{xd["name"]}`) && PathPrefix(`#{xd["path"]}`)"} else {match: "Host(`#{xd["name"]}`)"} end )], }, } end end def emit_domains (stack) compose.xdomains.each do |xd| stack << { kind: "Domain", apiVersion: "altaire.com/v1alpha1", metadata: { namespace: compose.name, name: namify(xd["name"]), }, spec: { domain: xd["name"], service: "traefik", }, } end end def emit_certificates (stack) compose.xdomains.each do |xd| stack << { kind: "Certificate", apiVersion: "cert-manager.io/v1alpha2", metadata: { namespace: compose.name, name: namify(xd["name"]), }, spec: { commonName: xd["name"], dnsNames: [xd["name"]], duration: "2160h0m0s", renewBefore: "360h0m0s", issuerRef: { kind: "ClusterIssuer", name: "letsencrypt-production", }, secretName: namify(xd["name"]), }, } end end def emit_volumes (stack) volumes.each do |volume| name, path = volume.split(":", 2) stack << { kind: "PersistentVolumeClaim", apiVersion: "v1", metadata: { namespace: compose.name, name: name, }, spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "1Gi", }, }, storageClassName: "do-block-storage", }, } end end def to_stack (stack) emit_deployment stack emit_services stack emit_middlewares stack emit_ingress_routes stack # emit_domains stack emit_certificates stack emit_volumes stack end end end end