<|start_filename|>src/pulumi_cljs/aws/ecs.cljs<|end_filename|>
(ns pulumi-cljs.aws.ecs
  "Utilities for building ECS Fargate tasks"
  (:require ["@pulumi/pulumi" :as pulumi]
            ["@pulumi/aws" :as aws]
            [pulumi-cljs.core :as p]
            [pulumi-cljs.aws :as aws-utils]))

(def ^:private elb-account-id
  {"us-east-1""127311923021"
   "us-east-2" "033677994240"
   "us-west-1" "027434742980"
   "us-west-2" "797873946194"
   "af-south-1" "098369216593"
   "ca-central-1" "985666609251"
   "eu-central-1" "054676820928"
   "eu-west-1" "156460612806"
   "eu-west-2" "652711504416"
   "eu-south-1" "635631232127"
   "eu-west-3" "009996457667"
   "eu-north-1" "897822967062"
   "ap-east-1" "754344448648"
   "ap-northeast-1"	"582318560864"
   "ap-northeast-2"	"600734575887"
   "ap-northeast-3"	"383597477331"
   "ap-southeast-1"	"114774131450"
   "ap-southeast-2" "783225319266"
   "ap-south-1"	"718504428378"
   "me-south-1" "076674570225"
   "sa-east-1" "507241528517"
   "us-gov-west-1" "048591011584"
   "us-gov-east-1" "190560391635"
   "cn-north-1"	"638102146993"
   "cn-northwest-1" "037604701340"})

(defn- access-log-bucket-policy
  "Generate a JSON bucket policy for access logs.

See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html#access-logging-bucket-permissions"
  [provider bucket]
  (let [bucket-contents-arn (str "arn:aws:s3:::" bucket "/*")]
    (p/json {:Version "2012-10-17"
             :Statement [{:Effect "Allow"
                          :Principal {:AWS (p/all [region (:region provider)]
                                             (str "arn:aws:iam::" (get elb-account-id region) ":root"))}
                          :Action "s3:PutObject"
                          :Resource bucket-contents-arn}
                         {:Effect "Allow"
                          :Principal {:Service "delivery.logs.amazonaws.com"}
                          :Action "s3:PutObject"
                          :Resource bucket-contents-arn}
                         {:Effect "Allow"
                          :Principal {:Service "delivery.logs.amazonaws.com"}
                          :Action "s3:GetBucketAcl"
                          :Resource (str "arn:aws:s3:::" bucket)}]})))

(defn- configure-dns
  "Configure A DNS entry for the load balancer using Route53"
  [provider name alb zone domain]
  (let [zone-name (str zone ".") ; Route53 wants a trailing period.
        zone-id (p/all [response (p/invoke aws/route53.getZone {:name zone-name} {:provider provider})]
                  (.-id response))]
    (p/resource aws/route53.Record (str name "-dns") alb
                 {:zoneId zone-id
                  :name domain
                  :type "A"
                  :aliases [{:name (:dnsName alb)
                             :zoneId (:zoneId alb)
                             :evaluateTargetHealth true}]})
    (p/resource aws/route53.Record (str name "-dns-wildcard") alb
      {:zoneId zone-id
       :name (str "*." domain)
       :type "A"
       :aliases [{:name (:dnsName alb)
                  :zoneId (:zoneId alb)
                  :evaluateTargetHealth true}]})))

(defn service
  "Build a multi-az AWS Fargate Service, with an optional load balancer
  for serving HTTPs traffic.

Config properties:

  :vpc-id - The VPC to use

  :container-port - The port on the Container that listens for inbound traffic

  :cluster-id - ECS cluster to use

  :task-count - The number of tasks to run

  :zone - The hosted zone in which to create a DNS entry.

  :subdomain - The subdomain to use for the DNS entry. May be null or an empty string if no subdomain is desired.

  :certificate-arn - The ARN of a SSL certificate stored in ACM

  :lb - Map of properties for the load balancer

        :ingress-cidrs - IP ranges that can access the service via the load balancer

        :subnets - Collection of Subnet ids in which to run the listener(s)

        :health-check - Health check configuration block 

  :task - Map of properties for the task

        :container-definitions - A collection of container definition maps.

        :cpu - CPU units for the task

        :memory - Memory units for the task

        :volumes - Collection of ECS Volume configurations

        :iam-statements - A collection of AWS IAM policy statements (maps with :Resource, :Effect & :Action keys) to add to the task's role.

        :subnets - Collection of subnet IDs in which to run the service

  :disconnect-lb - IF true, won't connect the service to the load balancer.
                   Useful for debugging services failing their health check.


Documentation for these values is available at (see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html).

  By default, runs one instance of the service in each subnet.

  If multiple container definitions are provided, the load balancer will be configured to connect to the first one."
  [parent provider name {:keys [zone
                                subdomain
                                certificate-arn
                                vpc-id
                                container-port
                                cluster-id
                                task-count
                                lb
                                task
                                disconnect-lb]}]
  (let [group (p/group name {:providers [provider] :parent parent})
        lb-sg (p/resource aws/ec2.SecurityGroup (str name "-lb") group
                {:vpcId vpc-id
                 :egress [{:protocol "-1"
                           :fromPort 0
                           :toPort 0
                           :cidrBlocks ["0.0.0.0/0"]}]
                 :ingress [{:protocol "tcp"
                            :fromPort 80
                            :toPort 80
                            :cidrBlocks (:ingress-cidrs lb)}
                           {:protocol "tcp"
                            :fromPort 443
                            :toPort 443
                            :cidrBlocks (:ingress-cidrs lb)}]})
        domain (if (empty? subdomain)
                 zone
                 (str subdomain "." zone))
        log-bucket-name (str "access-logs." domain)
        log-bucket (p/resource aws/s3.Bucket (str name "-access-logs") group
                     {:bucket log-bucket-name
                      :policy (access-log-bucket-policy provider log-bucket-name)})
        alb (p/resource aws/lb.LoadBalancer name group
              {:loadBalancerType "application"
               :securityGroups [(:id lb-sg)]
               :subnets (:subnets lb)
               :idleTimeout (or (:timeout lb) 60)
               :accessLogs {:bucket (:bucket log-bucket)
                            :enabled true}})
        target-group (p/resource aws/lb.TargetGroup name alb
                       {:port container-port
                        :healthCheck (:health-check lb)
                        :protocol "HTTP"
                        :targetType "ip"
                        :vpcId vpc-id})
        http-listener (p/resource aws/lb.Listener (str name "-http") alb
                        {:loadBalancerArn (:arn alb)
                         :port 80
                         :defaultActions [{:type "redirect"
                                           :redirect {:port 443
                                                      :protocol "HTTPS"
                                                      :statusCode "HTTP_301"}}]})
        https-listener (p/resource aws/lb.Listener (str name "-https") alb
                         {:loadBalancerArn (:arn alb)
                          :certificateArn certificate-arn
                          :port 443
                          :protocol "HTTPS"
                          :sslPolicy "ELBSecurityPolicy-FS-1-1-2019-08"
                          :defaultActions [{:type "forward"
                                            :targetGroupArn (:arn target-group)}]})
        log-group (p/resource aws/cloudwatch.LogGroup name group
                    {:name (str "/" name "/" (pulumi/getStack) "/ecs-logs")})
        role (aws-utils/role (str name "-task") group
               {:services ["ecs-tasks.amazonaws.com"]
                :policies ["arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"]
                :statements (:iam-statements task)})
        container-definitions (for [d (:container-definitions task)]
                                (assoc d :logConfiguration
                                  {:logDriver "awslogs"
                                   :options {"awslogs-group" (:name log-group)
                                             "awslogs-region" (:region provider)
                                             "awslogs-stream-prefix" (str (:name d) "-")}}))
        task-definition (p/resource aws/ecs.TaskDefinition name group
                          {:family (str name "-" (pulumi/getStack))
                           :executionRoleArn (:arn role)
                           :taskRoleArn (:arn role)
                           :networkMode "awsvpc"
                           :requiresCompatibilities ["FARGATE"]
                           :cpu (:cpu task)
                           :memory (:memory task)
                           :containerDefinitions (p/json container-definitions)
                           :volumes (:volumes task)})
        sg (p/resource aws/ec2.SecurityGroup (str name "-task") group
             {:vpcId vpc-id
              :ingress [{:protocol "tcp"
                         :fromPort container-port
                         :toPort container-port
                         :securityGroups [(:id lb-sg)]}]
              :egress [{:protocol "-1"
                        :fromPort 0
                        :toPort 0
                        :cidrBlocks ["0.0.0.0/0"]}]})
        service (p/resource aws/ecs.Service name group
                  {:cluster cluster-id
                   :launchType "FARGATE"
                   :taskDefinition (:arn task-definition)
                   :desiredCount task-count
                   :loadBalancers (when-not disconnect-lb
                                    [{:targetGroupArn (:arn target-group)
                                      :containerName (:name (first container-definitions))
                                      :containerPort container-port}])
                   :healthCheckGracePeriod 300
                   :deploymentMaximumPercent 200
                   :deploymentMinimumHealthyPercent 50
                   :networkConfiguration {:subnets (:subnets task)
                                          :securityGroups [(:id sg)]}})]
    (configure-dns provider name alb zone domain)
    {:dns (:dnsName alb)
     :security-group sg}))


<|start_filename|>src/pulumi_cljs/aws.cljs<|end_filename|>
(ns pulumi-cljs.aws
  "Utilities for working with AWS resources"
  (:require ["@pulumi/pulumi" :as pulumi]
            ["@pulumi/aws" :as aws]
            ["@pulumi/random" :as random]
            [pulumi-cljs.core :as p]))

(defn ssm-password
  "Create a new random password in SSM, and return the SSM Parameter resource.

   The Parameter key format is is /{project}/{stack}/{name}"
  [parent name]
  (let [pw (p/resource random/RandomPassword (p/id name) parent
             {:length 32
              :special false}
             {:additionalSecretOutputs ["result"]})]
    (p/resource aws/ssm.Parameter (p/id name) parent
                {:name (str "/" (pulumi/getProject)
                            "/" (pulumi/getStack)
                            "/" name)
                 :value (:result pw)
                 :type "SecureString"})))

(defn- assume-role-policy
  "Generate an AWS Assume Role policy granting access to the given services"
  [services]
  (p/json {:Version "2008-10-17"
           :Statement [{:Effect "Allow"
                        :Principal {:Service services}
                        :Action "sts:AssumeRole"}]}))

(defn- policy-stmt
  "Generate an AWS IAM Policy Statement that grants 'Allow' to the
   specified resource, for the given list of actions."
  [resource actions]
  {:Resource resource
   :Effect "allow"
   :Action actions})

(defn allow-stmt
  "Generate a JSON fragment for an AWS IAM policy, granting access to
  the given actions for the given resource ARN."
  [resources actions]
  {:Resource resources
   :Effect "Allow"
   :Action actions})

(defn role
  "Convenicne function for creating an IAM role. Takes the following options:

   services - List of AWS services (e.g, s3.amazonaws.com) that can assume this role
   policies - List of Policy resources to attach to the rule
   statements - List of Statements (json fragments) to add to the Rule (as an inline policy.)
   pass-role? - Add statements for iam:PassRole and iam:GetRole
   opts - options map for the Role resource"
  [name parent {:keys [services policies statements pass-role? opts]}]
  (let [role (p/resource aws/iam.Role name parent
               {:name name
                :assumeRolePolicy (assume-role-policy services)}
               (or opts {}))
        policies (or policies [])
        statements (or statements [])
        statements (if pass-role?
                     (conj statements (policy-stmt (:arn role) ["iam:PassRole" "iam:GetRole"]))
                     statements)
        policies (if (empty? statements)
                   policies
                   (let [policy-json (p/json {:Version "2012-10-17"
                                              :Statement statements})
                         policy (p/resource aws/iam.Policy name role
                                  {:policy policy-json})]
                     (conj policies policy)))]
    (doall (map-indexed (fn [idx policy]
                          (let [arn (if (instance? pulumi/Resource policy)
                                      (:arn policy)
                                      policy)]
                            (p/resource aws/iam.RolePolicyAttachment (str name "-" idx) role
                              {:role (:name role)
                               :policyArn arn}))) policies))
    role))



<|start_filename|>examples/hello-world/test/hello_world/main_test.cljs<|end_filename|>
(ns hello-world.main-test
  (:require [cljs.test :refer-macros [deftest is testing run-tests]]))

(deftest math-works
  (is (= (+ 1 1) 2)))


<|start_filename|>src/pulumi_cljs/core.cljs<|end_filename|>
(ns pulumi-cljs.core
  "Tools for working with Pulumi from CLJS"
  (:require ["@pulumi/pulumi" :as p]
            [clojure.walk :as walk]
            [clojure.string :as str])
  (:require-macros [pulumi-cljs.core]))

(defn resource
  "Create a Pulumi resource"
  ([type name parent args]
   (resource type name parent args {}))
  ([type name parent args options]
   (let [opts (if parent
                (assoc options :parent parent)
               options)]
     (new type name
       (clj->js args)
       (clj->js opts)))))

(defn load-cfg*
  "Load the Pulumi configuration for a project."
  [project]
  (p/Config. project))

(def load-cfg (memoize load-cfg*))

(defn cfg
  "Retrieve a single value from the Pulumi configuration for the current
  project. If the result is the string value 'false', returns boolean
  'false' instead (this avoids a lot of unexpected issues with YAML
  parsing.

  If the key is missing, throws an error unless a default value is supplied"
  ([key] (cfg key ::required))
  ([key default]
   (let [config (load-cfg (p/getProject))
         val (if (= default ::required)
               (.require ^p/Config config key)
               (.get ^p/Config config key))
         val (or val default)]
     (cond
       (= "false" (.toLowerCase val)) false
       (= "nil" (.toLowerCase val)) nil
       (= "null" (.toLowerCase val)) nil
       :else val))))

(defn cfg-obj
  "Retrieve a data structure value from the Pulumi configuration for the
  current project, converting data structures to Clojure data.

  If the key is missing, throws an error unless a default value is supplied."
  ([key] (cfg-obj key ::required))
  ([key default]
   (let [config (load-cfg (p/getProject))
         val (if (= default ::required)
               (.requireObject ^p/Config config key)
               (.getObject ^p/Config config key))]
     (if val
       (js->clj val :keywordize-keys true)
       default))))

(extend-protocol ILookup
  p/Resource
  (-lookup
    ([o k]
     (let [k (clj->js k)]
       (if-let [v (aget o k)]
         v
         (throw (ex-info (str "Resource does not have property or output with key " k)
                  {:key k})))))
    ([o k not-found]
     (if-let [v (aget o (clj->js k))]
       v
       not-found))))

(defn all*
  "Alias for pulumi.all since JS modules can't be imported into the Clojure macro namespace"
  [& args]
  (apply p/all args))

(defn prepare-output
  "Walk a data structure and replace all Resource objects with a map
  containing their Pulumi URN and provider ID, then convert to a JS
  object."
  [output]
  (clj->js
    (walk/prewalk (fn [form]
                    (if (instance? p/Resource form)
                      {:urn (:urn form)
                       :id (:id form)}
                      form))
      output)))

(defn json
  "Convert a Clojure data structure to a JSON string, handling nested
  Pulumi Output values"
  [data]
  (.apply (p/output (clj->js data)) #(js/JSON.stringify %)))

(defn group
  "Create a Component Resource to group together other sets of resources"
  [name opts]
  (new p/ComponentResource (str "group:" name) name (js-obj) (clj->js opts) false))

(defn id
  "Convenience function making it easier to generate standardized resource IDs
   based on the Pulumi project name."
  [& suffixes]
  (str/join "-" (cons (p/getProject) suffixes)))

(defn invoke
  "Invoke a Pulumi function. Converts args map and opts from
  ClojureScript to JavaScript. Returns a Pulumi Output, not a Promise,
  to avoid special handling. Does not convert the result to a CLJS
  object (CLJS objects behave oddly when wrapped in an Output)"
  [f & args]
  (p/output (apply f (map clj->js args))))


<|start_filename|>src/pulumi_cljs/core.clj<|end_filename|>
(ns pulumi-cljs.core
  "Macros for working with Pulumi from CLJS")

(defmacro all
  "Give an binding vector (similar to let) of symbols and Pulumi Outputs,
  execute the body when the concrete values are available, yielding a
  new Output.

  Under the hood, this macro wraps the pulumi.all and Output.apply
  functions."
  [bindings & body]
  (let [binding-pairs (partition 2 bindings)
       binding-forms (map first binding-pairs)
       output-exprs (map second binding-pairs)]
    `(.apply
       (all* (cljs.core/clj->js [~@output-exprs]))
       (fn [[~@binding-forms]]
         ~@body))))



<|start_filename|>examples/hello-world/index.js<|end_filename|>
"use strict";

// Import ClojureScript code from compilation directory
const stack = require("./generated/stack.js");

// Invoke the stack function
const outputs = stack();

// Use the stack functions return value as module exports, to make
// them Pulumi Stack Outputs.
Object.assign(exports, outputs);


<|start_filename|>test/pulumi_cljs/subnet_test.cljs<|end_filename|>
(ns pulumi-cljs.subnet-test
  (:require [cljs.test :refer-macros [deftest is testing run-tests]]
            [pulumi-cljs.subnet :as subnet]))

;; Example based tests built using https://www.subnetcalculator.org

(deftest allocate-test
  (is (= ["10.0.0.0/16"] (subnet/allocate "10.0.0.0/16" [16])))
  (is (= ["10.0.0.0/17"
          "10.0.128.0/17"] (subnet/allocate "10.0.0.0/16" [17 17])))
  (is (thrown-with-msg? ExceptionInfo #"Could not allocate"
        (subnet/allocate "10.0.0.0/16" [17 17 17])))
  (is (= ["10.0.0.0/20"
          "10.0.16.0/20"
          "10.0.32.0/19"] (subnet/allocate "10.0.0.0/16" [20 20 19])))
  (is (= ["10.0.0.0/20"
          "10.0.16.0/20"
          "10.0.64.0/18"] (subnet/allocate "10.0.0.0/16" [20 20 18])))
  (is (= ["10.0.0.0/20"
          "10.0.16.0/20"
          "10.0.64.0/18"
          "10.0.32.0/20"
          "10.0.128.0/17"
          "10.0.48.0/20"]
        (subnet/allocate "10.0.0.0/16" [20 20 18 20 17 20])))
  (is (thrown-with-msg? ExceptionInfo #"Could not allocate"
        (subnet/allocate "10.0.0.0/16" [20 20 18 20 17 20 20])))
)


<|start_filename|>examples/hello-world/src/hello_world/main.cljs<|end_filename|>
(ns hello-world.main
  (:require ["@pulumi/pulumi" :as pulumi]
            ["@pulumi/aws" :as aws]
            [pulumi-cljs.core :as p]))

(defn ^:export stack
  "Create the Pulumi stack, returning its outputs"
  []
  (let [bucket (p/resource aws/s3.Bucket "test-bucket" nil
                 {:bucket (p/cfg "bucket-name")})]

    (p/prepare-output {:bucket bucket})))
