(ns pulumicljs.execution.stack-processor (:require ["@pulumi/kubernetes" :as k8s] ["@local/crds/gateway" :as gateway-api] ["@local/crds/cert_manager" :as cert-manager] ["@pulumi/pulumi" :as pulumi] ["@pulumi/vault" :as vault] ["@pulumiverse/harbor" :as harbor] [pulumicljs.providers.defaults :as default] [pulumicljs.providers.vault :as vault-utils] [pulumicljs.execution.general :refer [deep-merge new-resource resource-factory deploy-stack-factory iterate-stack]] ["@pulumi/docker" :as docker] ["@pulumi/docker-build" :as docker-build] [clojure.walk :as walk] [clojure.string :as str] ["path" :as path] [configs :refer [cfg]] [pulumicljs.providers.k8s :as k8s-utils] [pulumicljs.providers.harbor :as harbor-utils] [pulumicljs.providers.docker :as docker-utils] [pulumicljs.execution.safe-fns :refer [safe-fns]]) (:require-macros [pulumicljs.execution.general :refer [p-> build-registry]])) (defn safe-parse-int [s] (let [n (js/parseInt s 10)] (if (js/isNaN n) nil n))) (defn string->int? [s] (and (string? s) (re-matches #"^-?\d+$" s))) (defn- coerce-value [v] (if (string->int? v) (safe-parse-int v) v)) (defn- is-output? [x] (some? (.-__pulumiOutput x))) (defn resolve-template [template secrets-map options-map] (let [data (merge (js->clj options-map) (js->clj secrets-map))] (walk/postwalk (fn [x] (cond (and (list? x) (contains? safe-fns (first x))) (let [f (get safe-fns (first x)) args (rest x)] (if (some is-output? args) (.apply (pulumi/all (clj->js args)) (fn [resolved-args] (apply f (js->clj resolved-args)))) (apply f args))) (and (list? x) (symbol? (first x))) (cond (= (first x) '->) (let [[_ resource-key & steps] x resource (get data resource-key)] (if-not resource x (reduce (fn [acc step] (cond (and (symbol? step) (str/starts-with? (name step) ".-")) (let [prop-name (subs (name step) 2)] (aget acc prop-name)) (and (list? step) (= (first step) 'get)) (get acc (second step) (nth step 2 nil)) :else acc)) resource steps))) (= (first x) 'get) (get data (second x) (nth x 2 nil)) (= (first x) 'get-in) (get-in data (second x) (nth x 2 nil)) :else x) (symbol? x) (if (contains? safe-fns x) x (let [kw (keyword x)] (cond (contains? data x) (coerce-value (get data x)) (contains? data kw) (coerce-value (get data kw)) :else x))) :else x)) template))) (defn generic-transform "Takes a creator function and executes it with resolved arguments, handling asynchronicity when secrets are present." [creator-fn opts base-values secrets options] (if (nil? secrets) (let [final-args (clj->js (deep-merge base-values (resolve-template opts {} options)))] (pulumi/output (creator-fn final-args))) (.apply secrets (fn [smap] (let [m (js->clj smap :keywordize-keys true) final-args (clj->js (deep-merge base-values (resolve-template opts m options)))] (creator-fn final-args)))))) (def component-specs {:vault {:provider-key :vault :provider-deps [:k8s]} ;; K8s Resources :k8s:namespace {:constructor (.. k8s -core -v1 -Namespace) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :namespace]) (:options env)))} :k8s:secret {:constructor (.. k8s -core -v1 -Secret) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :secret]) (:options env)))} :k8s:config-map {:constructor (.. k8s -core -v1 -ConfigMap) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :config-map]) (:options env)))} :k8s:deployment {:constructor (.. k8s -apps -v1 -Deployment) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :deployment]) (:options env)))} :k8s:service {:constructor (.. k8s -core -v1 -Service) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :service]) (:options env)))} :k8s:ingress {:constructor (.. k8s -networking -v1 -Ingress) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :ingress]) (:options env)))} :k8s:chart {:constructor (.. k8s -helm -v3 -Chart) :provider-key :k8s :defaults-fn (fn [env] (deep-merge ((get-in default/defaults [:k8s :chart]) (:options env)) (update-in (get-in (:options env) [:k8s:chart-opts]) [:values] #(deep-merge % (or (:yaml-values (:options env)) {})))))} :k8s:storage-class {:constructor (.. k8s -storage -v1 -StorageClass) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :storage-class]) (:options env)))} :k8s:pvc {:constructor (.. k8s -storage -v1 -PVC) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :pvc]) (:options env)))} :k8s:gateway {:constructor (.. gateway-api -v1 -Gateway) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :gateway]) (:options env)))} :k8s:httproute {:constructor (.. gateway-api -v1 -HTTPRoute) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :httproute]) (:options env)))} :k8s:cluster-issuer {:constructor (.. cert-manager -v1 -ClusterIssuer) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :cluster-issuer]) (:options env)))} :k8s:certificates {:constructor (.. cert-manager -v1 -Certificate) :provider-key :k8s :defaults-fn (fn [env] (let [{:keys [app-namespace is-prod?]} (:options env)] (p-> env :options :vault:prepare "stringData" .-domains #(vec (for [domain (js/JSON.parse %)] (let [clean-name (clojure.string/replace domain #"\." "-")] {:_suffix clean-name :metadata {:namespace app-namespace} :spec {:dnsNames [domain (str "*." domain)] :secretName (str clean-name "-tls") :issuerRef {:name (if is-prod? "letsencrypt-prod" "letsencrypt-staging") :kind "ClusterIssuer"}}}))))))} :k8s:certificate {:constructor (.. cert-manager -v1 -Certificate) :provider-key :k8s :defaults-fn (fn [env] ((get-in default/defaults [:k8s :certificate]) (:options env)))} ;; Docker Resources :docker:image {:constructor (.. docker-build -Image) :provider-key :docker :defaults-fn (fn [env] ((get-in default/defaults [:docker :image]) (:options env)))} ;; Harbor Resources :harbor:project {:constructor (.. harbor -Project) :provider-key :harbor :defaults-fn (fn [env] ((get-in default/defaults [:harbor :project]) (:options env)))} :harbor:robot-account {:constructor (.. harbor -RobotAccount) :provider-key :harbor :defaults-fn (fn [env] ((get-in default/defaults [:harbor :robot-account]) (:options env)))}}) (defmulti deploy-resource "Generic resource deployment multimethod. Dispatches on the fully-qualified resource keyword. Returns a map of {:resource (the-pulumi-resource) :common-opts-update {map-of-new-state}}." (fn [dispatch-key _config] dispatch-key)) (defmethod deploy-resource :default [dispatch-key full-config] (if-let [spec (get component-specs dispatch-key)] (let [app-name (:app-name full-config) dependsOn (:dependsOn full-config) provider-key (:provider-key spec) provider (get full-config provider-key) resource-class (:constructor spec) opts-key (keyword (str (name dispatch-key) "-opts")) component-opts (get full-config opts-key) env {:options full-config :secrets (:secrets full-config) :component-opts component-opts} raw-defaults (when-let [df (:defaults-fn spec)] (df env))] (if resource-class (let [base-creator (fn [final-args suffix] (let [final-name (if suffix (str app-name "-" suffix) app-name)] (new-resource resource-class final-name final-args provider dependsOn)))] {:resource (p-> raw-defaults #(let [defaults-list (if (vector? %) % [%]) is-multi? (vector? %) resources (doall (map-indexed (fn [idx item] (let [suffix (cond (:_suffix item) (:_suffix item) is-multi? (str idx) :else nil) clean-item (dissoc item :_suffix) item-creator (fn [resolved-args] (base-creator resolved-args suffix))] (generic-transform item-creator component-opts clean-item (:secrets env) full-config))) defaults-list))] (if is-multi? resources (first resources))))}) (throw (js/Error. (str "No :constructor found for spec: " dispatch-key))))) (throw (js/Error. (str "Unknown resource: " dispatch-key))))) (defmethod deploy-resource :vault:prepare [_ config] (let [prepare-opts (get config :vault:prepare-opts {}) defaults {:provider (:k8s config) :vault-provider (:vault config) :app-name (:app-name config) :app-namespace (:app-namespace config) :load-yaml (get config :vault-load-yaml false)} final-args (merge defaults prepare-opts) prepared-vault-data (try (vault-utils/prepare final-args) (catch js/Error e (js/console.error "!!! Error in :vault:prepare :" e) nil))] {:common-opts-update prepared-vault-data :resource (:bind-secrets prepared-vault-data)})) (defmethod deploy-resource :vault:retrieve [_ config] (let [retrieve-opts (get config :vault:retrieve-opts {}) defaults {:vault-provider (:vault config) :app-name (:app-name config) :app-namespace (:app-namespace config)} final-args (merge defaults retrieve-opts) retrieved-data (try (vault-utils/retrieve (:vault-provider final-args) (:app-name final-args) (:app-namespace final-args)) (catch js/Error e (js/console.error " Error in :vault:retrieve :" e) nil))] {:common-opts-update retrieved-data})) ;; https://www.pulumi.com/docs/iac/concepts/resources/dynamic-providers/ (defmethod deploy-resource :generic:execute [_ full-config] (let [app-name (:app-name full-config) dependsOn (:dependsOn full-config) component-opts (assoc (:execute-opts full-config) :pulumi-cfg (:pulumi-cfg full-config) :secrets (:secrets full-config) ) defaults {} exec-fn (:exec-fn full-config) resource-id (str app-name "-exec") provider #js {:create (fn [inputs-js] (js/Promise. (fn [resolve _reject] (resolve #js {:id resource-id :outs inputs-js})))) :delete (fn [id old-inputs-js] (js/Promise.resolve)) :update (fn [id old-inputs-js new-inputs-js] (js/Promise. (fn [resolve _reject] (resolve #js {:outs new-inputs-js}))))} gen (generic-transform #(clj->js (exec-fn (js->clj % :keywordize-keys true))) component-opts defaults (:secrets full-config) full-config) creator-fn (fn [inputs] (pulumi/dynamic.Resource. provider resource-id inputs (clj->js {:dependsOn (vec dependsOn)}))) resource (.apply gen #(creator-fn %))] {:resource resource})) (defn handle-keyword-item [last-resource item config common-opts] (let [dispatch-key item depends-on (when last-resource [last-resource]) final-config (merge config common-opts {:dependsOn depends-on}) result-map (deploy-resource dispatch-key final-config) resource (:resource result-map) opts-update (:common-opts-update result-map) resource-update (when resource {dispatch-key resource})] [resource resource-update (merge common-opts opts-update resource-update)])) (defn handle-list-item [last-resource item config common-opts] (let [provider-key (first item) resource-keys (rest item) nested-result (reduce (fn [nested-acc resource-key] (let [inner-last-resource (get nested-acc :last-resource) inner-resources-map (get nested-acc :resources) inner-common-opts (get nested-acc :common-opts) dispatch-key (keyword (str (name provider-key) ":" (name resource-key))) [new-resource new-resource-map new-common-opts] (handle-keyword-item inner-last-resource dispatch-key config inner-common-opts)] {:last-resource (or new-resource inner-last-resource) :resources (merge inner-resources-map new-resource-map) :common-opts new-common-opts})) {:last-resource last-resource :resources {} :common-opts common-opts} resource-keys)] [(:last-resource nested-result) (:resources nested-result) (:common-opts nested-result)])) (defn process-stack "Recursively processes a stack configuration, building a dependency chain. Returns a map of all created resources keyed by their dispatch keyword." [stack-items config initial-common-opts] (let [result (reduce (fn [acc item] (let [{:keys [last-resource common-opts]} acc [new-resource new-resources new-common-opts] (if (keyword? item) (handle-keyword-item last-resource item config common-opts) (handle-list-item last-resource item config common-opts))] {:last-resource (or new-resource last-resource) :resources-map (merge (:resources-map acc) new-resources) :common-opts new-common-opts})) {:last-resource nil :resources-map {} :common-opts initial-common-opts} stack-items)] (:resources-map result))) (defn deploy! [{:keys [pulumi-cfg resource-configs all-providers]}] (let [deployment-results (into {} (for [config resource-configs] (let [{:keys [stack app-name]} config _ (when (nil? config) (throw (js/Error. "Resource configs contain a nil value!"))) common-opts (merge all-providers (select-keys config [:app-name :app-namespace]) {:pulumi-cfg pulumi-cfg})] [app-name (process-stack stack config common-opts)])))] (clj->js deployment-results)))