├── .config └── dotnet-tools.json ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .paket └── Paket.Restore.targets ├── 01 Getting Started.md ├── 02 Creating an Azure Function.md ├── 03 Automated Tests.md ├── 04 Using Paket.md ├── 05 Visual Studio Solution.md ├── 06 Deploy to Azure.md ├── 07 Test Deployed Azure Function.md ├── 08 Make the function do something.md ├── 09 First Tidy-up.md ├── 10 Build with Fake.md ├── 11 Create an AWS Lambda.md ├── 12 Configure the AWS account.md ├── 13 Deploy Lambda to Aws.md ├── 14 Search CloudTrail logs.md ├── 15 Azure Javascipt Function.md ├── 16 AWS Javascript Lambda.md ├── 17 Second Tidy-up.md ├── 18 Store Pulumi stack info in cloud storage.md ├── 19 Creating an automated build.md ├── 20 Logging.md ├── 21 Extra Functionality.md ├── 22 Property-based tests in Hedgehog.md ├── 23 Bit-Rot, Parcel and Func.md ├── AwsConfig ├── AwsConfig.fsproj ├── Program.fs ├── Pulumi.yaml └── paket.references ├── Deployment.Aws ├── CloudTrail.fsx ├── Deployment.Aws.fsproj ├── Program.fs ├── Pulumi.Extras.Aws.fs ├── Pulumi.yaml └── paket.references ├── Deployment.Azure ├── Deployment.Azure.fsproj ├── Program.fs ├── Pulumi.yaml ├── PulumiExtras.Azure.fs └── paket.references ├── Deployment.Tests ├── Aws.fs ├── AwsJS.fs ├── AwsPulumiStackInstance.fs ├── Azure.fs ├── AzureJS.fs ├── AzurePulumiStackInstance.fs ├── Deployment.Tests.fsproj ├── PulumiStack.fs ├── deployment.runsettings.example └── paket.references ├── EverythingAsCodeFSharp.sln ├── MkRepo ├── MkRepo.fsproj ├── Program.fs ├── Pulumi.yaml └── paket.references ├── PulumiExtras.Core ├── Core.fs ├── PulumiExtras.Core.fsproj └── paket.references ├── README.md ├── Services.Clr ├── Logging.fs ├── Services.Clr.fsproj └── paket.references ├── Services.JS ├── Logging.fs ├── Services.JS.fsproj └── paket.references ├── Services ├── Logging.fs ├── Services.fsproj └── paket.references ├── Testing.Apis ├── TestWordValueEndpoints.fs ├── Testing.Apis.fsproj └── paket.references ├── Testing.AzureLocal ├── AzureFuncInstance.fs ├── Testing.AzureLocal.fsproj └── paket.references ├── Testing.Services ├── Logging.fs ├── Testing.Services.fsproj └── paket.references ├── WordValues.Aws.JS ├── .parcelrc ├── APIGatewayProxyRequest.fs ├── APIGatewayProxyResponse.fs ├── Function.fs ├── Services.fs ├── WordValues.Aws.JS.fsproj ├── package.json ├── paket.references └── yarn.lock ├── WordValues.Aws.Tests ├── Tests.fs ├── WordValues.Aws.Tests.fsproj └── paket.references ├── WordValues.Aws ├── Function.fs ├── Readme.md ├── WordValues.Aws.fsproj ├── aws-lambda-tools-defaults.json └── paket.references ├── WordValues.Azure.JS.Tests ├── Tests.fs ├── WordValues.Azure.JS.Tests.fsproj └── paket.references ├── WordValues.Azure.JS ├── .parcelrc ├── Function.fs ├── Interfaces.fs ├── Request.fs ├── Response.fs ├── Services.fs ├── WordValue │ └── function.json ├── WordValues.Azure.JS.fsproj ├── host.json ├── local.settings.json ├── package.json ├── paket.references └── yarn.lock ├── WordValues.Azure.Tests ├── Tests.fs ├── WordValues.Azure.Tests.fsproj └── paket.references ├── WordValues.Azure ├── Function.fs ├── Program.fs ├── WordValues.Azure.fsproj ├── host.json ├── local.settings.json └── paket.references ├── WordValues.Tests ├── TestCalculate.fs ├── WordValues.Tests.fsproj └── paket.references ├── WordValues ├── Calculate.fs ├── WordValues.fsproj └── paket.references ├── build.fsx ├── build.fsx.lock ├── paket.dependencies └── paket.lock /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "paket": { 6 | "version": "5.257.0", 7 | "commands": [ 8 | "paket" 9 | ] 10 | }, 11 | "fake-cli": { 12 | "version": "5.20.4", 13 | "commands": [ 14 | "fake" 15 | ] 16 | }, 17 | "fable": { 18 | "version": "3.2.1", 19 | "commands": [ 20 | "fable" 21 | ] 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET Core 3.1 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 3.1.x 20 | - name: Setup .NET 5 21 | uses: actions/setup-dotnet@v1 22 | with: 23 | dotnet-version: 5.0.x 24 | - name: Restore tools 25 | run: dotnet tool restore 26 | - name: Build 27 | run: dotnet fake build -f build.fsx -t Build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | # Fable output 353 | *.fs.js 354 | 355 | # Parcel output 356 | */.parcel-cache 357 | /WordValues.Azure.JS/WordValue/index.js* 358 | /WordValues.Aws.JS/WordValue/index.js* 359 | 360 | # Deployment files 361 | */publish.zip 362 | 363 | /Deployment.Tests/deployment.runsettings 364 | /.vscode 365 | -------------------------------------------------------------------------------- /01 Getting Started.md: -------------------------------------------------------------------------------- 1 | ## Get Pulumi 2 | ### As a download 3 | Get the installer from https://www.pulumi.com/docs/get-started/install/versions/ 4 | ### Using Chocolatey from PowerShell 5 | 1. Install PowerShell from the Windows Store https://www.microsoft.com/store/productId/9MZ1SNWT0N5D 6 | 7 | 1. Start pwsh as Administrator, and install Chocolatey following the instructions at https://chocolatey.org/install 8 | 9 | 1. `choco install pulumi` 10 | 11 | ## Create the Repository 12 | Life is too short to be poking around in web UIs trying to find all the buttons to press and set all the options the way you want them. That's why you might want to do everything in code - code it up once and apply it consistently, as often as required. 13 | So ... 14 | 15 | ## Making a Pulumi project to create the Repository 16 | The Pulumi app you installed above can create a .net project in F#, and you can add NuGet packages for the cloud objects you want to create. You describe what you want deployed by creating objects in the code. 17 | 18 | Pulumi keeps a record of the deployed cloud state (the 'stack'), in a Pulumi-hosted storage plan, or in your own cloud storage, or in the filesystem. Because this is a single-developer experiment, and I won't be merging etc, I'll use the filesystem and commit the stack state in a (private) repo. 19 | 20 | ```cmd 21 | mkdir MkRepo 22 | cd MkRepo 23 | mkdir .pulumi 24 | pulumi login file://./.pulumi 25 | pulumi new fsharp --force 26 | ``` 27 | The `--force` parameter is needed because the MkRepo folder isn't empty, due to the '.pulumi' folder we just created. There are a few setup questions: 28 | ``` 29 | This command will walk you through creating a new Pulumi project. 30 | Enter a value or leave blank to accept the (default), and press . 31 | Press ^C at any time to quit. 32 | project name: (MkRepo) 33 | project description: (A minimal F# Pulumi program) 34 | Deploy a repository 35 | Created project 'MkRepo' 36 | stack name: (dev) 37 | Created stack 'dev' 38 | Enter your passphrase to protect config/secrets:My secure passphrase 39 | Re-enter your passphrase to confirm:My secure passphrase 40 | 41 | Enter your passphrase to unlock config/secrets 42 | (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):My secure passphrase 43 | ... 44 | ``` 45 | ## Describing the repo in the Pulumi project 46 | ```cmd 47 | dotnet add package Pulumi.Github 48 | ``` 49 | Then change the code so that the `infra` function descibes a Github repository, and puts the repo URL in the dictionary it returns. 50 | ```fsharp 51 | module Program 52 | 53 | open Pulumi.FSharp 54 | open Pulumi.Github 55 | 56 | let infra () = 57 | let repo = 58 | Repository( 59 | "EverythingAsCodeFSharp", 60 | RepositoryArgs( 61 | Name = input "EverythingAsCodeFSharp", 62 | Description = input "Generated from MkRepo", 63 | Visibility = input "private", 64 | GitignoreTemplate = input "VisualStudio" 65 | ) 66 | ) 67 | 68 | // Export outputs here 69 | dict [ 70 | ("EverythingAsCodeFSharp.Clone", repo.HttpCloneUrl :> obj) 71 | ] 72 | 73 | [] 74 | let main _ = 75 | Deployment.run infra 76 | ``` 77 | ```cmd 78 | pulumi preview 79 | ``` 80 | You will be asked for your passphrase again. To stop this happening again, save it in the `PULUMI_CONFIG_PASSPHRASE` environment variable: 81 | ```cmd 82 | set PULUMI_CONFIG_PASSPHRASE=My secure passphrase 83 | ``` 84 | You should now see a brief description of the Github repo that will be create, but for this to work there are [confguration settings](https://Github.com/pulumi/pulumi-Github#configuration) that need to be in-place. 85 | 86 | Getting the token is described in [Github Docs](https://docs.Github.com/en/Github/authenticating-to-Github/creating-a-personal-access-token). Create a token with 'repo' and 'workflow' permissions and copy the value of the token. 87 | ```cmd 88 | pulumi config set Github:token --secret 89 | pulumi up 90 | ``` 91 | You will be asked to confirm the deployment, then creation should go ahead and the values shown from the dictionary return value will contain the git url for the new repo: 92 | ``` 93 | Outputs: 94 | + EverythingAsCodeFSharp.Clone: "https://Github.com/yourGithubusername/EverythingAsCodeFSharp.git" 95 | ``` 96 | You can then clone your new repo from the command line: 97 | ```cmd 98 | git clone https://Github.com/yourGithubusername/EverythingAsCodeFSharp.git 99 | ``` 100 | The 'MkRepo/.pulumi' folder will contain the details of the 'stack it just deployed. You'll need to preserve that - anything sensitive is encrypted, but just in case we add our own settings that we forget to mark as 'secret', use a private repo. -------------------------------------------------------------------------------- /02 Creating an Azure Function.md: -------------------------------------------------------------------------------- 1 | ## Setting up for Azure Functions 2 | Because at the time of writing, .net 5 isn't supported in the in-process hosting for Azure Functions, I'm following the [Isolated Process Worker instructions](https://docs.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-developer-howtos?pivots=development-environment-cli&tabs=browser). 3 | 4 | To test locally, you don't need an Azure account, but you do need the [Azure Function Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Ccsharp%2Cbash#v2) and the [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) 5 | 6 | ## An F# project for an HttpHandler 7 | There aren't (at the time of writing) any templates for the isolated hosting in F#, but we'll make do: 8 | ```cmd 9 | cd EverythingAsCodeFSharp 10 | func init WordValues.Azure --worker-runtime dotnetisolated --language F# 11 | ``` 12 | That gets us a C# project with no functions. So now to 'fix' it a bit: 13 | ```cmd 14 | cd WordValues.Azure 15 | rename WordValues_Azure.csproj WordValues.Azure.fsproj 16 | del program.cs 17 | ``` 18 | Edit the NuGet package references to include the Http Function extension worker 19 | ```diff 20 | 21 | 22 | 23 | 24 | + 25 | 26 | ``` 27 | And edit the last `ItemGroup` node in WordValues.Azure.fsproj to compensate for F# projects not automatically including the files from the source folder 28 | ```diff 29 | 30 | + 31 | + 32 | - 33 | + 34 | PreserveNewest 35 | 36 | + 37 | - 38 | PreserveNewest 39 | Never 40 | 41 | 42 | ``` 43 | Create Program.fs to start up the hosting 44 | ```fsharp 45 | module WordValues.Azure.Program 46 | 47 | open Microsoft.Extensions.Hosting 48 | 49 | let [] main _ = 50 | HostBuilder() 51 | .ConfigureFunctionsWorkerDefaults() 52 | .Build() 53 | .Run() 54 | 0 55 | ``` 56 | And Function.fs with a do-almost-nothing HttpHandler 57 | ```fsharp 58 | module WordValues.Azure.Function 59 | 60 | open System.Net 61 | open Microsoft.Azure.Functions.Worker 62 | open Microsoft.Azure.Functions.Worker.Http 63 | 64 | [] 65 | let run ([] request:HttpRequestData, executionContext:FunctionContext) : HttpResponseData = 66 | let response = request.CreateResponse(HttpStatusCode.OK) 67 | response.Headers.Add("Content-Type", "text/plain; charset=utf-8") 68 | response.WriteString("Hello") 69 | response 70 | ``` 71 | Now if you use `func` to start the project it will build and start up a local web server to test the function (you might be asked to allow func.exe's through the local firewall) 72 | ```cmd 73 | func start 74 | ... 75 | Azure Functions Core Tools 76 | Core Tools Version: 3.0.3388 Commit hash: fb42a4e0b7fdc85fbd0bcfc8d743ff7d509122ae 77 | Function Runtime Version: 3.0.15371.0 78 | 79 | Functions: 80 | WordValue: [GET] http://localhost:7071/api/WordValue 81 | ``` 82 | Opening that Url in the browser you should see the "Hello" message, and the console window should give some information about the function invocation. -------------------------------------------------------------------------------- /03 Automated Tests.md: -------------------------------------------------------------------------------- 1 | ## Add an automated test 2 | I'd already begun moving on to the next stages when I realised that I was failing at one of my objectives. 3 | I had tested the Azure Function locally with the `func` command, and a web browser - but of course I wouldn't remember to do that every time I made a change. I need to have checks like that done automatically (or at least automated). 4 | 5 | So, time to add a test project. 6 | ```cmd 7 | mkdir WordValues.Azure.Tests 8 | cd WordValues.Azure.Tests 9 | dotnet new xunit --language F# 10 | ``` 11 | That produces an F# project using [xUnit](https://xunit.net/) as the test framework. 12 | 13 | ### Testing under `func.exe` 14 | Http-triggered functions can be tested by connecting to the endpoint that is created when `func start` is used in the Azure Function project's source folder. 15 | 16 | To do this, we can use xUnit's [class fixtures](https://xunit.net/docs/shared-context#class-fixture) 17 | - Run `func start` in the class fixture with a known port. 18 | - When a test needs to access the function, check the `func.exe` process has not exited, and try to connect to that port. If all is ok, return some connection context. 19 | - When the fixture is disposed, kill the process we started. 20 | ### Testing the Http-Triggered function 21 | In the test case, we can use the class fixture to get the base Url (which will check that the hosting process hasn't gone away) 22 | 23 | [FsHttp](https://github.com/ronaldschlenker/FsHttp) provides helpers to make the request/response code simple, and then the status code and response body can be checked in the test. 24 | 25 | It's a slow test to run compared to real unit-tests, but it's faster than running func manually and pasting urls into the web browser, and it can be done automatically by just running `dotnet test` -------------------------------------------------------------------------------- /04 Using Paket.md: -------------------------------------------------------------------------------- 1 | ## Using Paket 2 | Because `paket` is very good at keeping all the NuGet package versions in the solution up-to-date and consistent, we'll switch this repo over to use paket. 3 | 4 | Create the dotnet tool manifest, install paket, and convert the repo to use paket. 5 | ```cmd 6 | cd EverythingAsCodeFSharp 7 | dotnet new tool-manifest 8 | dotnet tool install paket 9 | dotnet paket convert-from-nuget 10 | ``` 11 | This will change a few things in the folder. 12 | - There will ba a dotnet-tools.json which specifies `paket` as a tool for this repo. After cloning a repo, you can use `dotnet tool restore` to install all the tools used in the repo. 13 | - There's a `paket.dependencies` file that lists all the NuGet packages in the repo, and their versions. 14 | - The `paket.lock` file is used to fix all the NuGet packages to a certain versions. This will mean that a `dotnet restore` on a fresh clone will download the versions from your commit. 15 | - The `.fsproj` files have had their `` nodes removed, and the package names are now listed in a `paket.references` file in each folder. 16 | - An msbuild file `.paket\Paket.Restore.targets` has been added to help in the build process. -------------------------------------------------------------------------------- /05 Visual Studio Solution.md: -------------------------------------------------------------------------------- 1 | ## Make a Visual Studio solution (.sln) file 2 | As we've now got more than one project, I'll create a solution file to keep them together and make managing their inter-dependencies easier. 3 | 4 | Just using the "Open Folder" feature in Visual Studio, or the Ionide extension in Visual Studio Code could work here too, 5 | but I find Visual Studio's build tooling more straightforward to use. 6 | 7 | ### Create the solution file 8 | ```cmd 9 | dotnet new sln --name EverythingAsCodeFSharp 10 | ``` 11 | 12 | ### Set up the projects 13 | This can be done in Visual Studio by opening the .sln file and using the Solution Explorer. 14 | Alternatively, the projects can be arranged from the command-line. 15 | ```cmd 16 | dotnet sln add WordValues.Azure WordValues.Azure.Tests 17 | dotnet add WordValues.Azure.Tests reference WordValues.Azure 18 | ``` 19 | 20 | If I wasn't making a solution file at all, the `dotnet add ... reference ...` command would still be important, 21 | to ensure that the function project gets built before the tests run under `dotnet test`. 22 | -------------------------------------------------------------------------------- /06 Deploy to Azure.md: -------------------------------------------------------------------------------- 1 | ## Deploying to Azure 2 | 3 | ### Set up the Azure access 4 | First create an Azure account, you can get free tier services, free trials and free credit (for example when signing up, and through Visual Studio Dev Essentials). 5 | 6 | Install the Azure CLI, either via the [installer](https://github.com/Azure/azure-cli/releases) or Chocolatey (from and Administrator PowerShell prompt): 7 | ```powershell 8 | choco install azure-cli 9 | ``` 10 | 11 | Then (after re-opening your command prompt) you should be able to use the Azure CLI 12 | ```cmd 13 | az login 14 | ``` 15 | which will open a browser window to login. 16 | 17 | ### Create the Pulumi project to deploy to the cloud 18 | From the command prompt in the repository 19 | ```cmd 20 | mkdir Deployment 21 | cd Deployment 22 | ``` 23 | Optionally, if you're storing data in your (private) repo instead 24 | ```cmd 25 | mkdir .pulumi 26 | pulumi login file://./.pulumi 27 | ``` 28 | And then create the project 29 | ```cmd 30 | pulumi new azure-fsharp --force 31 | ``` 32 | But the default project template doesn't use paket, so edit the fsproj 33 | ```diff 34 | - 35 | - 36 | - 37 | - 38 | ``` 39 | and add the packages 40 | ```cmd 41 | dotnet paket add Pulumi.AzureNative --project Deployment 42 | dotnet paket add Pulumi.FSharp --project Deployment 43 | ``` 44 | By the time the deployment was coded up, there wasn't much left of the templated code. It was built from: 45 | * [Pulumi example code for a C# Azure function](https://github.com/pulumi/examples/tree/master/azure-cs-functions) 46 | * The [Azure function isolated hosting guide](https://docs.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-developer-howtos?pivots=development-environment-cli&tabs=browser) 47 | 48 | By performing the command-line deployment explained in the Azure guide, you can find the correct settings for the Pulumi objects. 49 | For example: 50 | ```cmd 51 | az functionapp create --resource-group functions1234 --consumption-plan-location westeurope --runtime dotnet-isolated --runtime-version 5.0 --functions-version 3 --name wvazureclie --storage-account sa8c6d7 52 | ``` 53 | Then retrieve the settings from Pulumi code. 54 | ```fsharp 55 | let fn = WebApp.Get("wvazurecli", input "/subscriptions/...../resourceGroups/functions1234/providers/Microsoft.Web/sites/wvazureclie") 56 | let webAppKind = fn.Kind 57 | let webAppConfigSettings = fn.SiteConfig |> Outputs.apply (fun c -> c.AppSettings |> Seq.map (fun o -> $"{o.Name}={o.Value}") |> String.concat ",") 58 | let webAppConfigNetVer = fn.SiteConfig |> Outputs.apply (fun c -> c.NetFrameworkVersion) 59 | let serverFarmId = fn.ServerFarmId 60 | ``` 61 | and return the values to be shown in the preview of `pulumi up` 62 | ```fsharp 63 | dict [ 64 | "fn.Kind", webAppKind :> obj 65 | "fn.SiteConfig.AppSettings", webAppConfigSettings :> obj 66 | "fn.SiteConfig.NetVer", webAppConfigNetVer :> obj 67 | "fn.ServerFarmId", serverFarmId :> obj 68 | ] 69 | ``` 70 | You can also diff the json produced by the Export tab of the Azure Web Portal for the `az func`-deployed and Pulumi-deployed versions to check that 71 | everything is there. 72 | 73 | ### The finished deployment code 74 | There are a couple of rough edges, particularly hardcoding part of the function URL for the stack output 75 | dictionary at the end. But opening the URL in the web browser shows the "Hello" message from the function 76 | running in 'the cloud'. -------------------------------------------------------------------------------- /07 Test Deployed Azure Function.md: -------------------------------------------------------------------------------- 1 | ## Testing the Deployed Function 2 | Again, testing the deployed function by copying URLs from the command prompt and pasting into a web client is a bit too manual. 3 | It's time-consuming and easy to forget to do. So it's time to automate again. 4 | ## Pulumi.Automation 5 | The Pulumi Automation sdk allows us to grab values from the stack output instead of having to copy-paste, and feed them into automated test. 6 | 7 | ```cmd 8 | mkdir Deployment.Tests 9 | cd Deployment.Tests 10 | dotnet new library --language F# 11 | ``` 12 | Add a paket.references file for the same test assemblies used in the other test project (so they're all already in the paket.dependencies) 13 | ``` 14 | Microsoft.NET.Test.Sdk 15 | SchlenkR.FsHttp 16 | xunit 17 | xunit.runner.visualstudio 18 | coverlet.collector 19 | FSharp.Core 20 | ``` 21 | And then add the Pulumi Automation Api to interact with our Pulumi stack 22 | ```cmd 23 | dotnet paket add Pulumi.Automation --project Deployment.Tests 24 | dotnet restore 25 | ``` 26 | Again using xUnit's [class fixtures](https://xunit.net/docs/shared-context#class-fixture) we can get the outputs from the Pulumi stack 27 | ```fsharp 28 | type PulumiStack (stackName, folder, envVars) = 29 | let outputs = 30 | task { 31 | let args = LocalProgramArgs(stackName, folder, EnvironmentVariables = envVars) 32 | let! stack = LocalWorkspace.SelectStackAsync(args) 33 | let! outputs = stack.GetOutputsAsync() 34 | return outputs 35 | } 36 | 37 | member _.GetOutputs() = 38 | outputs.Result 39 | ``` 40 | Then in the test, we can grab the "endpoint" output from the `stack` fixture 41 | ```fsharp 42 | let outputs = stack.GetOutputs() 43 | let testUri = Uri(outputs.["endpoint"].Value :?> string, UriKind.Absolute) 44 | ``` 45 | And use FsHttp to test the deployed function. -------------------------------------------------------------------------------- /08 Make the function do something.md: -------------------------------------------------------------------------------- 1 | **remember to put the ece type back in whichever branch it got deleted ** 2 | 3 | ## Making the function do something 4 | A 'Hello World' function is a trivial demo, so now I'll make it slightly less trivial. 5 | 6 | Puzzles set by users in the real-world [geocaching](https://www.geocaching.com/play) game sometimes involve 7 | converting answers in the form of a word into digits to make up co-ordinates of a hidden item. 8 | 9 | The method is simple enough - each letter in the textual answer gets assigned a value where A=1, B=2, ... Z=26 10 | and then the values are summed. 11 | ### An implementation and tests 12 | The word value implementation isn't particularly interesting, but I'll provide a naive implementation of 13 | it and some tests. 14 | 15 | I don't want to be spinning up hosting processes, or deploying & invoking the real web service to test the algorithm, 16 | so I'll extract the implementation from the cloud function (so 'WordValues' and 'WordValues.Azure'). 17 | 18 | For tests, I'm using xUnit and [Swensen Unquote](https://github.com/SwensenSoftware/unquote). With Unquote you write the test condition in an F# 19 | quotation, and if a test fails you get some explanation of what failed. For example, if there was a 20 | bug in the test where I forget there are two Ls in HELLO - then the test 21 | ```fsharp 22 | [] 23 | let ``Value of HELLO is correct`` () = 24 | test <@ Calculate.wordValue "HELLO" = 8 + 5 + 12 + 15 @> 25 | ``` 26 | would produce the output 27 | ``` 28 | Message: 29 | 30 | 31 | Calculate.wordValue "HELLO" = 8 + 5 + 12 + 15 32 | 52 = 13 + 12 + 15 33 | 52 = 25 + 15 34 | 52 = 40 35 | false 36 | 37 | Expected: True 38 | Actual: False 39 | ``` 40 | 41 | ### Property-based tests 42 | Supposing with that test fixed, I was happy that the implementation was complete. 43 | ``` 44 | let wordValue (text : string) : int = 45 | text.ToUpper() 46 | |> Seq.sumBy (fun letter -> (int letter) - (int 'A') + 1) 47 | ``` 48 | 49 | But *just in case*, I'll create some property-based tests. Property-based testing describes some 50 | properties of how the function should behave when given unknown inputs. 51 | 52 | Some properties that we could test: 53 | The value of some text is the same as the value of its all-upper-case version 54 | The value of some text is the same as the value of its all-lower-case version 55 | The value of some text should be the same as the value of the reversed of the text 56 | The value should be at most 26 * the character count 57 | ```fsharp 58 | [] 59 | let ``Value of text is same as value of upper case`` (str : string) = 60 | Calculate.wordValue str = Calculate.wordValue (str.ToUpper()) 61 | 62 | [] 63 | let ``Value of text is same as value of lower case`` (str : string) = 64 | Calculate.wordValue str = Calculate.wordValue (str.ToLower()) 65 | 66 | [] 67 | let ``Value of text is same as value of reversed text`` (str : string) = 68 | Calculate.wordValue str = Calculate.wordValue (reverse str) 69 | 70 | [] 71 | let ``Value of text is below maximum value`` (str : string) = 72 | Calculate.wordValue str <= 26 * str.Length 73 | ``` 74 | And those tests failed instantly, because FsCheck supplied 'null' for the strings. 75 | 76 | Since we're in F# we can be fairly certain that's not going to be something we pass to the wordValue calculation. 77 | By changing the parameter type in the `Property` test from `string` to `NonNull` we can 78 | remove the `null`s from the tests. 79 | 80 | Next failure is 81 | ``` 82 | FsCheck.Xunit.PropertyFailedException : 83 | Falsifiable, after 11 tests (1 shrink) (StdGen (824747591, 296879486)): 84 | Original: 85 | NonNull "X]" 86 | Shrunk: 87 | NonNull "]" 88 | ``` 89 | So the test failed for "X]", and then FsCheck tried to find a smaller repro case - "]". 90 | 91 | After filtering out non-letters in the calculation, the tests pass. But I'll change the return value to 92 | be a value and a warning message, and add a test of the warnings too. 93 | 94 | The property tests noticed that the warnings were different if the source text was reversed, so I 95 | made the warning report each ignored character once, in character-code order. 96 | 97 | ### Returning the calculation results from the Azure Function 98 | I had to add some code to convert the result of the calculation (the value and any warnings) to json 99 | using `System.Text.Json`'s JsonSerializer. 100 | 101 | I also added some tests to the WordValues.Azure.Tests project to check the returned json. 102 | 103 | I also had to read the word to evaluate from the query parameters of the HttpRequest. Of course the 104 | first thing that happened was that I got `null`s for the word where the parameter was missing from the URL. 105 | 106 | I guess that serves me right for claiming we wouldn't see those in F#. To isolate them, I added 107 | ```fsharp 108 | module NameValueCollection = 109 | let tryFind (key : string) (nvc : NameValueCollection) = 110 | nvc.[key] |> Option.ofObj 111 | ``` 112 | to turn `null`s into Option.None 113 | 114 | I also found that I could speed up the function hosting under the test by passing `--no-build` provided 115 | I can find the build output folder for the function assembly, so I added some code to the tests to 'guess' that path. 116 | 117 | 118 | -------------------------------------------------------------------------------- /09 First Tidy-up.md: -------------------------------------------------------------------------------- 1 | ## First tidy-up 2 | 3 | There are a few tasks that I should now probably do in this repo just to keep things tidy & up-to-date etc. 4 | 5 | * Add missing file references to projects and solution file (documentation files, paket.reference etc) for easy access from the IDE. 6 | * Set the test projects to be libraries by adding `Library` to the project files 7 | * Update all the Nuget Packages 8 | * Remove the version numbers from paket.dependencies 9 | * Limit the packages to the frameworks used in the solution by adding `framework: auto-detect` 10 | * `dotnet paket update` -------------------------------------------------------------------------------- /10 Build with Fake.md: -------------------------------------------------------------------------------- 1 | ## Build script (using Fake) 2 | 3 | Add Fake to the dotnet tools in the repo 4 | ```cmd 5 | dootnet tool install fake-cli 6 | ``` 7 | 8 | And create a simple fake build script, `build.fsx` 9 | ```fsharp 10 | #if FAKE 11 | #r """paket: 12 | source https://api.nuget.org/v3/index.json 13 | nuget FSharp.Core 4.7.2 14 | nuget Fake.Core.Target 15 | //""" 16 | #endif 17 | 18 | #load "./.fake/build.fsx/intellisense.fsx" 19 | 20 | open Fake.Core 21 | 22 | Target.create "Noop" ignore 23 | 24 | // Default target 25 | Target.runOrDefault "Noop" 26 | ``` 27 | The `#r """paket:` opens a block of paket-syntax package references. It's not a standard F# script syntax, Fake will pass it on to Paket to load the packages that the build script used. Placing that block under the `#if FAKE` condition stops the editor from trying to parse it. 28 | 29 | The "intellisense.fsx" file does not exist until fake is run for the first time, so there will be an 'could not find file' error reported on that line, and 'undefined symbol' errors on the following line, so we should run fake a first time. 30 | 31 | ```cmd 32 | dotnet fake build 33 | ``` 34 | 35 | The rest of the script creates a target called "Noop" which does nothing (`ignore` will be called to do the build, which does nothing and returns 'unit'). The target "Noop" is run as the default target. 36 | 37 | After the build script is run, there will be a `build.fsx.lock` file created which fixes the package versions used by the fake script (and should be committed to the repo) and a `.fake` folder with some build info that doesn't need to be committed). 38 | 39 | ### Adding some real build targets 40 | I'm going to add some targets that save me having to do a sequence of operations. 41 | * Build, then Run the Unit Tests 42 | * `dotnet publish` the Azure function, then test it 43 | * Push the Azure function to Azure, then test it 44 | 45 | So to do that, I'll define some targets for the bits and some dependencies between them. One thing I'm not keen on in Fake is the use of repeated literal strings for target names, so I'm defining: 46 | ```fsharp 47 | module Target = 48 | let create name description body = 49 | Target.description description 50 | Target.create name body 51 | name 52 | 53 | let noop = Target.create "Noop" "Does nothing" ignore 54 | 55 | // Default target 56 | Target.runOrDefault noop 57 | ``` 58 | And now the magic string "Noop" only appears once, and any subesquent use is a variable and mis-types get caught by the syntax checking. Also it hides the built-it Target.create, so I can't accidentally use the original one. And also (again) it forces me to add a description for the target. 59 | 60 | ### Build and test 61 | To build with the dotnet cli, I needed to add the package `nuget Fake.DotNet.Cli`. But fake doesn't automatically update the referenced packages while the lock file exists, so I also deleted `build.fsx.lock` and re-ran `dotnet fake build`. After re-opening the build.fsx in the editor, I got intellisense on Fake.DotNet references. 62 | 63 | The `Fake.DotNet.Cli` package provides the `Fake.DotNet` namespace and the `DotNet.build` and `DotNet.test` methods. The first parameter to these methods allows you to customise the parameters, or leave them unchanged by passing `id`. 64 | 65 | ```fsharp 66 | let solutionFile = "EverythingAsCodeFSharp.sln" 67 | 68 | let build = 69 | Target.create "Build" "Build the solution" (fun _ -> 70 | DotNet.build id solutionFile 71 | ) 72 | 73 | // Default target 74 | Target.runOrDefault build 75 | ``` 76 | So now `dotnet fake build` builds the solution. 77 | ```fsharp 78 | let unitTests = 79 | Target.create "UnitTests" "Run the unit tests" (fun _ -> 80 | DotNet.test id "WordValues.Tests" 81 | ) 82 | ``` 83 | And `dotnet fake build -t UnitTests` runs the tests. 84 | 85 | ### Dependencies 86 | ```fsharp 87 | open Fake.Core.TargetOperators 88 | 89 | ... 90 | 91 | build ==> unitTests 92 | ``` 93 | tells Fake that to build the `unitTests` target, we need to build the `build` target first. 94 | 95 | ### dotnet publish the Azure Function and test under func.exe 96 | Basically just the `dotnet publish` and `dotnet test` tasks, along with the dependency. 97 | ```fsharp 98 | build ==> publishAzureFunc ==> localTestAzureFunc 99 | ``` 100 | ### deploy to Azure and then test the deployed version 101 | With a helper to run exes and check the return code 102 | ```fsharp 103 | let runExe exe workingFolder arguments = 104 | Command.RawCommand (exe, Arguments.ofList arguments) 105 | |> CreateProcess.fromCommand 106 | |> CreateProcess.withWorkingDirectory workingFolder 107 | |> CreateProcess.ensureExitCode 108 | |> Proc.run 109 | |> ignore 110 | ``` 111 | (the ignore is because Proc.run returns a `ProcessResult` but we already set up checking with `ensureExitCode`) 112 | 113 | We can define targets to run the Pulumi deployment and make running the Deployment.Tests suites dependent on that. 114 | ```fsharp 115 | let pulumiDeploy = 116 | Target.create "PulumiDeploy" "Test the Azure Function locally" (fun _ -> 117 | runExe 118 | "pulumi" 119 | (solutionFolder"Deployment") 120 | [ "up"; "-y"; "-s"; "dev" ] 121 | ) 122 | 123 | let deployedTestAzureFunc = 124 | Target.create "DeployedTestAzureFunc" "Test the Azure Function after deployment" (fun _ -> 125 | DotNet.test id "Deployment.Tests" 126 | ) 127 | 128 | pulumiDeploy ==> deployedTestAzureFunc 129 | ``` 130 | The `` operator comes from `Fake.IO.FilesystemOperators` and does the a similar thing to `Path.Combine`. -------------------------------------------------------------------------------- /11 Create an AWS Lambda.md: -------------------------------------------------------------------------------- 1 | ## Create an AWS Lambda 2 | 3 | To ensure none of this is getting Azure-specific, I'll add and deploy AWS Lambda with all the same functionality and tests. 4 | 5 | After signing up for an AWS account, install the AWS CLI - e.g. using Chocolatey from an Administrator PowerShell prompt 6 | ```pwsh 7 | choco install awscli 8 | ``` 9 | 10 | And install the dotnet templates for AWS projects (from your non-elevated prompt) 11 | ```cmd 12 | dotnet new -i Amazon.Lambda.Templates 13 | ``` 14 | Then create a new project with the .net 5 runtime, in a temporary folder (so the projects can be moved about to match the folder structure in the repo) 15 | ```cmd 16 | mkdir AwsTemp 17 | cd AwsTemp 18 | dotnet new lambda.CustomRuntimeFunction --name WordValues.Aws --language F# 19 | move WordValues.Aws\src\WordValues.Aws .. 20 | move WordValues.Aws\test\WordValues.Aws.Tests .. 21 | ``` 22 | ### Tweaking the projects 23 | In the WordValues.Aws.fsproj file 24 | ```diff 25 | - 26 | - 27 | - 28 | - 29 | - 30 | ``` 31 | And then add the packages with paket 32 | ```cmd 33 | dotnet paket add FSharp.Core --project WordValues.Aws 34 | dotnet paket add Amazon.Lambda.Core --project WordValues.Aws 35 | dotnet paket add Amazon.Lambda.RuntimeSupport --project WordValues.Aws 36 | dotnet paket add Amazon.Lambda.Serialization.SystemTextJson --project WordValues.Aws 37 | ``` 38 | For WordValues.Aws.Tests.fsproj 39 | ```diff 40 | - 41 | - 42 | - 43 | - 44 | - 45 | - 46 | - 47 | - 48 | 49 | - 50 | + 51 | 52 | ``` 53 | And fix up the package references 54 | ```cmd 55 | dotnet paket add FSharp.Core --project WordValues.Aws.Tests 56 | dotnet paket add Microsoft.NET.Test.Sdk --project WordValues.Aws.Tests 57 | dotnet paket add Amazon.Lambda.Core --project WordValues.Aws.Tests 58 | dotnet paket add Amazon.Lambda.TestUtilities --project WordValues.Aws.Tests 59 | dotnet paket add Microsoft.NET.Test.Sdk --project WordValues.Aws.Tests 60 | dotnet paket add xunit --project WordValues.Aws.Tests 61 | dotnet paket add xunit.runner.visualstudio --project WordValues.Aws.Tests 62 | dotnet paket add coverlet.collector --project WordValues.Aws.Tests 63 | ``` 64 | Now the project should build and its tests should pass. 65 | ### Making the AWS Lambda do the same thing the Azure Function does 66 | We need to change the return type of the lambda to a APIGatewayProxyResponse, which requires another package. 67 | ```cmd 68 | dotnet paket add Amazon.Lambda.APIGatewayEvents --project WordValues.Aws 69 | ``` 70 | Then make the Aws Lambda function similar to the Azure Function version. 71 | ```diff 72 | - let functionHandler (input: string) (context: ILambdaContext) = 73 | + let functionHandler (word : string) (context: ILambdaContext) = 74 | 75 | - match input with 76 | - | null -> String.Empty 77 | - | _ -> input.ToUpper() 78 | + if not (isNull word) then 79 | + let result = Calculate.wordValue word 80 | + let content = JsonSerializer.Serialize<_>(result, JsonSerializerOptions(IgnoreNullValues = true)) 81 | + 82 | + APIGatewayProxyResponse( 83 | + StatusCode = int HttpStatusCode.OK, 84 | + Body = content, 85 | + Headers = dict [ ("Content-Type", "application/json") ] 86 | + ) 87 | + else 88 | + APIGatewayProxyResponse( 89 | + StatusCode = int HttpStatusCode.BadRequest, 90 | + Body = "Required query parameter 'word' was missing", 91 | + Headers = dict [ ("Content-Type", "text/plain;charset=utf-8") ] 92 | + ) 93 | ``` 94 | And finally, the tests should be replaced with 'ports' of the Azure Function tests. -------------------------------------------------------------------------------- /12 Configure the AWS account.md: -------------------------------------------------------------------------------- 1 | ## Configure the AWS account 2 | ### First login 3 | - Log in to the console at https://console.aws.amazon.com/lambda 4 | - Find the IAM dashboard 5 | - Enable MFA 6 | 7 | Then, following the AWS IAM instructions for [creating non-root users](https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started_create-admin-group.html) we can set up some users for our deployments. 8 | ### Create a project 9 | ```cmd 10 | mkdir AwsConfig 11 | cd AwsConfig 12 | mkdir .pulumi 13 | pulumi login file://./.pulumi 14 | pulumi new fsharp -f -n AwsConfig 15 | pulumi config set aws:region eu-west-2 16 | ``` 17 | Edit AwsConfig.fsproj 18 | ```diff 19 | - 20 | - 21 | - 22 | ``` 23 | And add the project references 24 | ```cmd 25 | dotnet paket add FSharp.Core --project AwsConfig 26 | dotnet paket add Pulumi.FSharp --project AwsConfig 27 | dotnet paket add Pulumi.Aws --project AwsConfig 28 | ``` 29 | ### Script the admin user creation 30 | The code to create the admin user needs: 31 | - An 'Administrator' group to belong to 32 | ```fsharp 33 | let administrators = 34 | Iam.Group( 35 | "administrators", 36 | Iam.GroupArgs( 37 | Name = input "Administrators" 38 | ) 39 | ) 40 | ``` 41 | - Administrator access for that group (which is a built-in AWS policy) 42 | ```fsharp 43 | let administratorsPolicy = 44 | Iam.GroupPolicyAttachment( 45 | "administratorsPolicy", 46 | Iam.GroupPolicyAttachmentArgs( 47 | Group = io administrators.Name, 48 | PolicyArn = input "arn:aws:iam::aws:policy/AdministratorAccess" 49 | ) 50 | ) 51 | ``` 52 | - The 'admin' user, with membership of the 'Administrators' group 53 | ```fsharp 54 | let admin = 55 | Iam.User( 56 | "adminUser", 57 | Iam.UserArgs( 58 | Name = input "admin" 59 | ) 60 | ) 61 | 62 | let adminGroupMemberships = 63 | Iam.UserGroupMembership( 64 | "adminInAdministrators", 65 | Iam.UserGroupMembershipArgs( 66 | User = io admin.Name, 67 | Groups = inputList [ io administrators.Name ] 68 | ) 69 | ) 70 | ``` 71 | ### Deploying the first time, running as the 'root' user 72 | Since we need to use the root user to create our non-root users, navigate to the [AWS IAM dashboard]( 73 | https://console.aws.amazon.com/iam/home) logged in as root. 74 | - Choose "My security credentials" from the drop-down 75 | - Expand "Access keys" 76 | - Click "Create new access key" 77 | - Click "Show access key" 78 | 79 | Then store the security values in environment variables and use Pulumi to create the administrator user 80 | ```cmd 81 | set AWS_ACCESS_KEY_ID=Access key id value 82 | set AWS_SECRET_ACCESS_KEY=secret access key value 83 | pulumi up 84 | ``` 85 | Once that is done, you can delete the access key from the root user. 86 | ### Adding a 'deploy' user, running as the new 'admin' user 87 | Finally we can create a 'deploy' user which will be used from our scripts to do Pulumi deployments. 88 | The 'admin' user will only be used to adjust the definition of the 'deploy' user when necessary. 89 | 90 | The 'Devops' group and 'deploy' user are the same as 'Administrators' and 'admin', but without the group policy for AdministratorAccess. 91 | 92 | In the outputs of the Pulumi `infra` method, we'll return the key and secret to use when deploying our Aws cloud components as the 'deploy' user. 93 | 94 | ```fsharp 95 | let deployAccess = 96 | Iam.AccessKey( 97 | "deployKey", 98 | Iam.AccessKeyArgs( 99 | User = io deploy.Name 100 | ) 101 | ) 102 | 103 | dict [ 104 | "deploy.AWS_ACCESS_KEY_ID", deployAccess.Id :> obj 105 | "deploy.AWS_SECRET_ACCESS_KEY", deployAccess.Secret :> obj 106 | ] 107 | ``` 108 | To create our 'deploy' user we can now use the 'admin' user we created earlier, but we need to set the environment variables 109 | to the values for the user. From the IAM dashboard: 110 | - expand 'Access management' 111 | - select 'Users' 112 | - click 'admin' 113 | - select the 'Security credentials' tab 114 | - create an access key as before and get the secret too. 115 | ```cmd 116 | set AWS_ACCESS_KEY_ID=Access key id value 117 | set AWS_SECRET_ACCESS_KEY=secret access key value 118 | pulumi up 119 | ``` 120 | Which should create the 'deploy' user, but not show the keys for using it (because they're secrets). To see them use: 121 | ```cmd 122 | pulumi stack --show-secrets 123 | ``` 124 | and `deploy.AWS_ACCESS_KEY_ID` and `deploy.AWS_SECRET_ACCESS_KEY` are the settings to be used in the project to deploy the function. 125 | -------------------------------------------------------------------------------- /14 Search CloudTrail logs.md: -------------------------------------------------------------------------------- 1 | ## Searching downloaded CloudTrail logs 2 | The CloudTrail Management Console's web page for Event History has only basic filtering - by user name etc. 3 | 4 | To perform more interesting searches, and dump the results all in one list instead of having to look at each event individually, we can download the events as json. Using the Json Type Provider from FSharp.Data we get intellisense to help browse the properties of the logged events. 5 | 6 | I downloaded a CloudWatch log file as json, and then used it as the example data for the type provider (I did this in Visual Studio Code with Ionide, because Visual Studio was having problems with type providers at the time of writing) 7 | ```fsharp 8 | #r "nuget:FSharp.Data" 9 | open FSharp.Data 10 | 11 | type EventHistory = JsonProvider< @"c:\users\mark\downloads\event_history.json"> 12 | ``` 13 | Now when an event file is loaded, the intellisense offers properties etc to filter the events on (after typing `events.`, `record.` or `r.` in the following example) 14 | ```fsharp 15 | let events = EventHistory.Load @"c:\users\mark\downloads\event_history.json" 16 | 17 | let accessDenied = 18 | events.Records 19 | |> Seq.filter (fun r -> r.ErrorCode = Some "AccessDenied") 20 | |> Seq.distinctBy (fun r -> r.ErrorMessage) 21 | 22 | for record in accessDenied do 23 | printfn $"{record.ErrorMessage}" 24 | ``` -------------------------------------------------------------------------------- /16 AWS Javascript Lambda.md: -------------------------------------------------------------------------------- 1 | ## Running the WordValue function on Aws as JavaScript 2 | This was a repeat of `15 Azure Javascipt Function.md` but for Aws. 3 | 4 | Fairly quickly, I found that [James Randall](https://github.com/JamesRandall) had done this already in a an article [Creating AWS Lambda with F# and Fable](https://www.jamesdrandall.com/posts/creating_aws_lambda_with_fsharp_and_fable/) and had made the JavaScript interop bits for Fable already. 5 | 6 | But since I was on a mission of learning by doing, I set about doing it myself. I created the project the same way as `WordValues.Azure.JS` but without the Azure func, because Aws doesn't need any magic json files etc. 7 | ```cmd 8 | mkdir WordValues.Aws.JS 9 | cd WordValues.Aws.JS 10 | dotnet new classlib --language F# --framework netstandard2.1 11 | dotnet add reference ..\WordValues\WordValues.fsproj 12 | dotnet paket add FSharp.Core --project WordValues.Aws.JS 13 | dotnet paket add Fable.Core --project WordValues.Aws.JS 14 | dotnet paket add Thoth.Json --project WordValues.Aws.JS 15 | yarn add parcel-bundler --dev 16 | ``` 17 | I couldn't find any Typescript files with the ApiGatewayRequest and ApiGatewayResponse types that the .net lambda used, so I took the source for the C# files and edited them to become the F# types for Fable. 18 | 19 | I downloaded [APIGatewayProxyRequest.cs](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayProxyRequest.cs) and [APIGatewayProxyResponse.cs](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.APIGatewayEvents/APIGatewayProxyResponse.cs) and committed them to git, then edited them into valid F#. 20 | 21 | I copied the way the `IDictionary` types are implemented, from the Typescript files that ts2fable made for the Azure step, and I made the fields into `_ option` types because they're sometimes missing from the json (for example `queryParameters`). 22 | 23 | For the response type, I created an interface so that I could use `createEmpty` and mutation to constuct them, rather than having to supply all the fields. I might revisit that at a later point because the mutation (in this and the Azure version) feels like a bit of a cop-out. 24 | ### Building the JavaScript version 25 | I made a Fake build target like the one for Azure, and added the block for the yarn 'build' target to bundle the JavaScript. 26 | ```fsharp 27 | let publishAwsJSLambda = 28 | Target.create "PublishAwsJSLambda" "Publish the Aws Lambda as Javascript" (fun _ -> 29 | let projectFolder = solutionFolder "WordValues.Aws.JS" 30 | DotNet.exec dotNetOpt "fable" "WordValues.Aws.JS" |> ignore 31 | Yarn.exec "build" (fun opt -> { opt with WorkingDirectory = projectFolder }) 32 | let publishZip = System.IO.Path.Combine(projectFolder, "publish.zip") 33 | let zipFiles = 34 | !! (projectFolder "WordValue/**/*.*") 35 | Fake.IO.Zip.createZip (projectFolder"WordValue") publishZip "" Fake.IO.Zip.DefaultZipLevel false zipFiles 36 | ) 37 | ``` 38 | The zip file is created relative to the WordValue sub-folder, because there are no files above that folder to include (the Azure version had a json file in the parent folder). 39 | ```diff 40 | { 41 | + "scripts": { 42 | + "build": "parcel build Function.fs.js --out-dir WordValue --out-file index.js" 43 | + }, 44 | "devDependencies": { 45 | "parcel-bundler": "^1.12.5" 46 | } 47 | } 48 | ``` 49 | ### Publishing the JavaScript Lambda 50 | Publishing the JavaScript version involved adding a new blob for the `publish.zip` created by the Fake build target, the lambda had to specify Node14 as the runtime, and give an entry point, but after that it was duplicating pretty much everything that the .net lambda needed. The whole script could now do with a refactor to tidy that up. 51 | 52 | Once the JavaScript lambda was deployed, I could test it in the Aws Console webui, and I found that I'd got the handler wrong in the lambda creation. The description for the handler setting in the Aws Console linked to a page that described the format as being basically `jsfilename.entrypoint`, so I updated the value in the Pulumi script to `index.functionHandler`. 53 | ```fsharp 54 | let jsLambda = 55 | Lambda.Function( 56 | "wordJsLambda", 57 | Lambda.FunctionArgs( 58 | Runtime = inputUnion2Of2 Lambda.Runtime.NodeJS14dX, 59 | Handler = input "index.functionHandler", 60 | Role = io lambdaRole.Arn, 61 | S3Bucket = io jsCodeBlob.Bucket, 62 | S3Key = io jsCodeBlob.Key, 63 | SourceCodeHash = input jsPublishFileHash 64 | ) 65 | ) 66 | ``` 67 | I also found that I needed to make the function a JavaScript promise or it wouldn't work - I found that by comparing my function declaration with James's in the repo for the article mentioned above. That required another Fable package 68 | ```cmd 69 | dotnet paket add Fable.Promise --project WordValues.Aws.JS 70 | ``` 71 | So the declaration was then: 72 | ```fsharp 73 | let functionHandler (request : APIGatewayProxyRequest, _) = 74 | promise { 75 | .... 76 | let response = createEmpty 77 | response.headers <- createEmpty 78 | .... 79 | return response 80 | } 81 | ``` 82 | ### Testing the deployed JavaScript lambda 83 | This just required a new test fixture which was a copy of Aws.fs in the Deployment.Tests project, but fetching the jsEndpoint value from the Pulumi stack. 84 | 85 | -------------------------------------------------------------------------------- /17 Second Tidy-up.md: -------------------------------------------------------------------------------- 1 | ## Second tidy-up 2 | ### Make Azure projects explicit 3 | Some of the Deployment stuff was named "Deployment" or "Deployment.Aws", so I renamed "Deployment" to "Deployment.Azure". 4 | ### Remove duplicated Pulumi helpers from Deployment projects 5 | Moved the PulumiExtras.fs file into its own library used from the Deployment.* projects, I used; 6 | ```cmd 7 | dotnet new classlib --language F# --framework netcoreapp3.1` 8 | ``` 9 | because the Pulumi libraries target netcoreapp3.1 10 | 11 | Later I renamed the project as PulumiExtras.Core so it didn't start `Pulumi.` like an official lib would. 12 | ### Remove duplication from all the Deployment.Tests fixtures 13 | I moved the tests into an abstract base class which takes an endpoint getter. The base class is abstract so that the runner doesn't try to instanatiate it as a separate fixture. 14 | ### Reduce duplication in the Deployment.Aws project 15 | There was lots of duplication for the .net lambda vs the JS lambda, mostly because of all the setup around the Gateway. Unfortunately, each component needs references to multiple previous components. 16 | 17 | I found a solution that kept me fairly happy (at least for now) by: 18 | - adding functions to build each component 19 | - each function takes an anonymous record for context 20 | - the function returns the context with a new field added, eg 21 | ```fsharp 22 | let anonymousAnyMethod name (ctx : {| Resource : Resource; RestApi : RestApi |}) = 23 | let method = 24 | ApiGateway.Method( 25 | name, 26 | ApiGateway.MethodArgs( 27 | HttpMethod = input "ANY", 28 | Authorization = input "NONE", 29 | RestApi = io ctx.RestApi.Id, 30 | ResourceId = io ctx.Resource.Id 31 | ) 32 | ) 33 | {| ctx with Method = method |} 34 | ``` 35 | - these functions can then be piped. 36 | ### Reduce duplication in the Deplyoyment.Azure project 37 | - Extracted some code to upload the code to blob storage and get the url for creating the Azure Function 38 | - Extracted some code to create the Azure Function and return its endpoint 39 | ### Reduce duplication between Azure tests and AzureJS tests 40 | ```cmd 41 | mkdir Testing.AzureLocal 42 | cd Testing.AzureLocal 43 | dotnet new classlib --language F# 44 | git mv ..\WordValues.Azure.Tests\AzureFunc.fs AzureFuncInstance.fs 45 | del ..\WordValues.Azure.JS.Tests\AzureFunc.fs 46 | del Library.fs 47 | dotnet paket add FSharp.Core --project Testing.AzureLocal 48 | dotnet paket add xunit --project Testing.AzureLocal 49 | dotnet restore ..\EverythingAsCodeFSharp.sln 50 | ``` 51 | And then fix up the build by adding project references to replace the AzureFunc.fs file. 52 | 53 | While testing under the Test Explorer, I found that there was a race condition where the tests would hang if two test fixtures accessed the same stack concurrently - so I added the xunit `Collection` attribute to group the Aws tests together and the Azure tests together. That way the Azure tests could run at the same time as the Aws tests, but not at the same time as other Azure tests. 54 | 55 | I defined some constants for the collection names, so that they couldn't be mis-spelled causing a hang due to the mismatch. 56 | ```fsharp 57 | module TestCollections = 58 | let [] AzureStack = "Azure Stack Tests" 59 | let [] AwsStack = "Aws Stack Tests" 60 | // and used like: 61 | [] 62 | ``` 63 | Also, the local tests of the WordValues endpoints in WordValues.Azure.Tests and WordValues.Azure.JS.Tests were almost identical to the ones already refactored in Deployment.Tests - so I moved TestWordValueEndpoints.fs to a new Testing.Apis project, and used that from all those tests. 64 | 65 | Because the Testing.* projects reference xunit, the test explorer was producing warnings in the output window about a missing testhost.dll 66 | ``` 67 | Microsoft.VisualStudio.TestPlatform.ObjectModel.TestPlatformException: Unable to find C:\git\EverythingAsCodeFSharp\Testing.Apis\bin\Debug\net5.0\testhost.dll. Please publish your test project and retry. 68 | at Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Hosting.DotnetTestHostManager.GetTestHostPath(String runtimeConfigDevPath, String depsFilePath, String sourceDirectory) 69 | ``` 70 | so I added package references to those projects 71 | ```cmd 72 | dotnet paket add Microsoft.NET.Test.Sdk --project Testing.AzureLocal 73 | dotnet paket add Microsoft.NET.Test.Sdk --project Testing.Apis 74 | ``` 75 | That resulted in the less obtrusive warning 76 | ``` 77 | No test is available in C:\git\EverythingAsCodeFSharpTidy\Testing.Apis\bin\Debug\net5.0\Testing.Apis.dll. Make sure that test discoverer & executors are registered and platform & framework version settings are appropriate and try again. 78 | ``` 79 | ### Package updates 80 | Updated the dotnet packages with 81 | ```cmd 82 | dotnet tool list 83 | dotnet tool update paket 84 | dotnet tool update fake-cli 85 | dotnet tool update fable 86 | dotnet paket update 87 | ``` 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /18 Store Pulumi stack info in cloud storage.md: -------------------------------------------------------------------------------- 1 | ## Store the Pulumi stack info in cloud storage 2 | Managing the stack storage in the repo was becoming a problem by this point, especially with branches and multiple local clones. 3 | 4 | Pulumi can also store its state to Azure Blob Storage, Amazon S3 or Google Cloud Storage. Because I already had code to create Aws Infrastructure, I decided to extend the AwsConfig project to create an S3 bucket to store the state for the Deployment.* projects. 5 | 6 | So the state for the AwsConfig would be in a repo, and the state for the Azure Functions and Aws Lambdas would be in the bucket created by the Aws Infrastructure project. 7 | ### Creating the storage 8 | I added the Pulumi code to add a non-public bucket, and export the s3 path for pulumi login 9 | ```fsharp 10 | let deploymentStateBucket = 11 | S3.Bucket( 12 | "deploymentState", 13 | S3.BucketArgs() 14 | ) 15 | 16 | let deploymentStateAccess = 17 | S3.BucketPublicAccessBlock( 18 | "deploymentStateAccess", 19 | S3.BucketPublicAccessBlockArgs( 20 | Bucket = io deploymentStateBucket.Id, 21 | BlockPublicAcls = input true, 22 | BlockPublicPolicy = input true, 23 | RestrictPublicBuckets = input true, 24 | IgnorePublicAcls = input true 25 | ) 26 | ) 27 | 28 | let backendStateRoot = 29 | deploymentStateBucket.BucketName 30 | |> Outputs.apply (fun bn -> $"s3://{bn}") 31 | ``` 32 | And export the path for Pulumi state storage 33 | ```diff 34 | dict [ 35 | "deploy.AWS_ACCESS_KEY_ID", deployAccess.Id :> obj 36 | "deploy.AWS_SECRET_ACCESS_KEY", deployAccess.Secret :> obj 37 | + "backendStateRoot", backendStateRoot :> obj 38 | ] 39 | ``` 40 | 41 | The after running `pulumi up`, the otputs include 42 | backendStateRoot: s3://deploymentstate-xxxxxxx 43 | ### Migrating the stacks into cloud storage 44 | Now I needed to move the stacks from `Deployment.Azure\.pulumi\ ... dev` to `azure-dev` and `Deployment.Aws\.pulumi\ ... dev` to `aws-dev` in `s3://deploymentstate-xxxxxxx` 45 | 46 | Pulumi needs to know the passphrase for the yaml files, the region for the s3 storage, and the credentials (the ones for the Aws `deployment` user) 47 | ```cmd 48 | set PULUMI_CONFIG_PASSPHRASE=My secure passphras 49 | set AWS_ACCESS_KEY_ID=... 50 | set AWS_SECRET_ACCESS_KEY=... 51 | set AWS_REGION=eu-west-2 52 | ``` 53 | The procedure for migrating a stack is in the [Pulumi States and Backends](https://www.pulumi.com/docs/intro/concepts/state/#migrating-between-backends) documentation, but one complication is that both the stacks were called 'dev' under different projects. The way round that is to import it under the original name and then rename it. 54 | ```cmd 55 | cd Deployment.Azure 56 | pulumi stack select dev 57 | pulumi stack export --show-secrets --file dev.json 58 | pulumi logout 59 | pulumi login s3://deploymentstate-xxxxxxx 60 | pulumi stack init dev 61 | pulumi stack import --file dev.json 62 | pulumi stack rename azure-dev 63 | rmdir /s .pulumi 64 | pulumi stack select azure-dev 65 | 66 | cd Deployment.Aws 67 | pulumi login file://.pulumi 68 | pulumi stack select dev 69 | pulumi stack export --show-secrets --file dev.json 70 | pulumi logout 71 | pulumi login s3://deploymentstate-xxxxxxx 72 | pulumi stack init dev 73 | pulumi stack import --file dev.json 74 | pulumi stack rename aws-dev 75 | rmdir /s .pulumi 76 | pulumi stack select aws-dev 77 | ``` 78 | ### Checking the tests still passed 79 | I then ran the Deployment.Tests to check that they could still read the urls etc from the Pulumi stacks. They couldn't. 80 | ``` 81 | System.AggregateException : One or more errors occurred. (code: -1 82 | stdout: 83 | stderr: error: failed to load checkpoint: blob (key ".pulumi/stacks/dev.json") (code=Unknown): MissingRegion: could not find region configuration 84 | 85 | ) 86 | ``` 87 | The problems were: 88 | 1. `AwsPulumiStackInstance` and `AzurePulumiStackInstance` were both passing the stack name `dev` to the `PulumiStack` test helper where they now needed `aws-dev` and `azure-dev`. 89 | 2. The stacks needed to include environment variables for `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` to find the backend storage. 90 | 91 | But I don't want to have to hard-code those variables in the code, before long I would commit their real value to the repo. In fact, I'd rather not have `PULUMI_CONFIG_PASSPHRASE` in there either. But [environment variables can be placed in a `.runsettings` file](https://docs.microsoft.com/en-us/visualstudio/test/configure-unit-tests-by-using-a-dot-runsettings-file) instead, which will be easier to keep out of the repo with a `.gitignore` reference. 92 | 93 | I created a `deployment.runsettings` file in the `Deployment.Tests` folder 94 | ```xml 95 | 96 | 97 | 98 | 99 | My secure passphrase 100 | eu-west-2 101 | ... 102 | ... 103 | 104 | 105 | 106 | 107 | ``` 108 | and added a property to `Deployment.Tests.fsproj`: 109 | ```xml 110 | $(MSBuildProjectDirectory)\deployment.runsettings` 111 | ``` 112 | ### Making tests fail helpfully 113 | With the code as it stands, running the tests (say with `dotnet test`) will just report that the `deployment.runsettings` file does not exist. 114 | 115 | Instead, I added (`git add -f deployment.runsettings`) a file with a commented-out `EnvironmentVariables` section, and repurposed the `envVars` parameter to the `PulumiStack` test helper to be a list of environment variables that we want to check for. If they're not found then we can fail with a helpful message. 116 | 117 | That way, running the tests in a fresh clone will report what's missing - and the either the file can be edited or the environment variables could be set insted. 118 | 119 | -------------------------------------------------------------------------------- /19 Creating an automated build.md: -------------------------------------------------------------------------------- 1 | ## Creating an automated build 2 | This is built in yaml, following the instructions at https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions 3 | 4 | The file needs to be in .github/workflows, so create build.yaml based on [The example dotnet build](https://github.com/actions/starter-workflows/blob/main/ci/dotnet.yml) 5 | 6 | Editing the file in Visual Studio Code will find the schema and offer intellisense etc during editing. 7 | 8 | I changed the trigger to read 9 | ```yaml 10 | on: push 11 | ``` 12 | And pushed to the repo to see what happened. The workflow showed up in the "Actions" tab of the repo on github, and the build failed after 18 seconds. 13 | 14 | ### Making a working build pipeline 15 | The first error was a dumb mistake on the fake command line (I missed the 'build' part of `dotnet fake build -f build.fsx -t Build`) 16 | 17 | (Fix, commit, push, wait for the build) 18 | 19 | The next problem was 20 | ``` 21 | /home/runner/.nuget/packages/microsoft.net.sdk.functions/3.0.11/build/Microsoft.NET.Sdk.Functions.Build.targets(32,5): error : It was not possible to find any compatible framework version [/tmp/1qf2uc4m.onf/WorkerExtensions.csproj] 22 | ``` 23 | 24 | I suspected this was because that package targets `netcoreapp3.1`, so I added an extra build step to the yaml to install that version of the .net core sdk. 25 | 26 | That was enough to get it building, so I changed the triggers in the yaml file to build on Pull Request or on a Push to the `main` branch. 27 | 28 | ### The default branch 29 | The example syntax for building the default branch and Pull Requests 30 | ```yaml 31 | on: 32 | push: 33 | branches: [ $default-branch ] 34 | pull_request: 35 | branches: [ $default-branch ] 36 | ``` 37 | didn't seem to work, but it looked likely to be due to the `default-branch` macro not having been set. 38 | 39 | I found that Pulumi could set this with the `DefaultBranch` property of `RepositoryArgs`, but that was marked as obsolete. The preferred approach is to use a `BranchDefault` resource: 40 | ```fsharp 41 | let defaultBranch = 42 | Pulumi.Github.BranchDefault( 43 | "EverythingAsCodeFSharpDefaultBranch", 44 | BranchDefaultArgs( 45 | Repository = io repo.Name, 46 | Branch = input "main" 47 | ) 48 | ) 49 | ``` 50 | 51 | However, that still didn't work. A [StackOverflow answer](https://stackoverflow.com/a/65723433/59371) explains that the `[ $default-branch ]` is for workflow *templates*, not the workflows themselves. The macro would be replaced by `main` for a workflow yaml file. -------------------------------------------------------------------------------- /20 Logging.md: -------------------------------------------------------------------------------- 1 | ## Logging 2 | Because debugging cloud services isn't (usually?) a case of starting a debugger and single-stepping the code, I want to have logging in the services to see what was going on if anything breaks. 3 | 4 | Because the logic is independent of which cloud it's in (and even whether it's in the cloud at all), I want to make an interface for the logger and some F# functions to do the logging. 5 | 6 | ### The basic version 7 | I created an F# project called "Services" and added: 8 | * an interface for logging which doesn't depend on any particular logging framework (although it's based on `Microsoft.Extensions.Logging`) 9 | * an F# module for logging things 10 | * a type for the logged events, to get away from the function overloading style. 11 | ```fsharp 12 | namespace Services 13 | 14 | open System 15 | 16 | type LogLevel = 17 | | Trace 18 | | Debug 19 | | Info 20 | | Warn 21 | | Error 22 | | Critical 23 | 24 | [] 25 | type LogEvent = { Message : string; EventId : int; Params : obj[] } with 26 | static member Create(message, [] pars) = { Message = message; EventId = 0; Params = pars } 27 | 28 | type ILogger = 29 | abstract Log : LogLevel -> LogEvent -> unit 30 | 31 | module Log = 32 | let info (logger : ILogger) event = 33 | logger.Log Info event 34 | ``` 35 | (I'll add other Logging level functions later). 36 | ### Using the logger 37 | Initially the level was in the event, but this layout made the function usage look nicer - like: 38 | ```fsharp 39 | let wordValue (logger : ILogger) (text : string) : WordValue = 40 | Log.info logger (LogEvent.Create("wordValue of {text}", text)) 41 | ``` 42 | The message / parameter syntax is the same as `Microsoft.Extensions.Logging` uses, and there are three things I dislike about it: 43 | 1. `("one is {one} and two is two", one, two)` will lose the value of `two` because of the missing brackets 44 | 2. `("one is {one} and two is {two}", one)` will throw at runtime because no parameter is supplied for the second value 45 | 3. `("one is {one} and two is {two}", two, one)` will produce misleading results because the parameters don't match the string. 46 | 47 | 1 and 2 can be at mitigated against by having the test methods use a logger implementation that just checks for this sort of error, rather than a dumb mock object that just ignores logging requests. 48 | ### Implementation 49 | * Azure Functions in .net can use the `Microsoft.Extensions.Logging.ILogger<_>_` from Dependency Injection 50 | * AWS Lambdas in .net can use the (misleadingly named) `Amazon.Lambda.Logging.AspNetCore` nuget package. 51 | 52 | For these two, I created a Services.Clr project which implements the ILog interface in terms of `Microsoft.Extensions.Logging.ILogger` 53 | 54 | * Azure Functions in Javascript can use the `Context.log` interface supplied to the function, which has logging functions for `info`, `error` etc. 55 | * Aws Lambdas in Javascript can use console logging (so `System.Console.WriteLine` from Fable). 56 | 57 | Neither of these methods support structured logging, so I created a `Services.JS` project to hold a Json encoder for the state passed to the `ILog` implementations. 58 | ### Test implementation 59 | I added a `TestLogger` class and a singleton `TestLogger.Default` instance to use from the tests. Then because `TestLogger.Default` would be the first parameter to all the calls to `Calculate.wordValue`, I added a local `Calculate.wordValue` which partially bound that parameter ~~to make the diffs simpler~~ out of laziness. 60 | ```fsharp 61 | // Partially bind the Testing Logger implementation 62 | module Calculate = 63 | let wordValue = Calculate.wordValue (TestLogger.Default) 64 | ``` 65 | ### Deployment 66 | I added a target to the `build.fsx` Fake script that can be used to publish all the functions / lambdas. That is as simple as making a target that does nothing (so, using `ignore` as the body) and listing the publish targets as dependencies. 67 | ```fsharp 68 | let publishAll = 69 | Target.create "PublishAll" "Publish all the Functions and Lambdas" ignore 70 | 71 | publishAzureFunc ==> publishAll 72 | publishAzureJSFunc ==> publishAll 73 | publishAwsLambda ==> publishAll 74 | publishAwsJSLambda ==> publishAll 75 | ``` 76 | I also found that I had forgotten to ask yarn to install the packages as part of the Javascript builds, and that I'd wrongly assumed that the `Dotnet.exec` and `Proc.run` tasks would fail the build on a non-zero exit code from the tool - so I fixed those too. 77 | ```fsharp 78 | type ProcessHelpers = 79 | static member checkResult (p : ProcessResult) = 80 | if p.ExitCode <> 0 81 | then failwithf "Expected exit code 0, but was %d" p.ExitCode 82 | 83 | static member checkResult (p : ProcessResult<_>) = 84 | if p.ExitCode <> 0 85 | then failwithf "Expected exit code 0, but was %d" p.ExitCode 86 | ``` 87 | ```diff 88 | let projectFolder = solutionFolder "WordValues.Azure.JS" 89 | + let yarnParams (opt : Yarn.YarnParams) = { opt with WorkingDirectory = projectFolder } 90 | - DotNet.exec dotNetOpt "fable" "WordValues.Azure.JS" |> ignore 91 | + DotNet.exec dotNetOpt "fable" "WordValues.Azure.JS" |> ProcessHelpers.checkResult 92 | + Yarn.install yarnParams 93 | - Yarn.exec "build" (fun opt -> { opt with WorkingDirectory = projectFolder }) 94 | + Yarn.exec "build" yarnParams 95 | ``` 96 | Once that was deployed, I tested the functions / lambdas in the Azure Portal / Aws Console and checked that the console output logging saw the info messages. -------------------------------------------------------------------------------- /21 Extra Functionality.md: -------------------------------------------------------------------------------- 1 | ## Extra Functionality 2 | Mostly just as a way of introducing some extra features to the project, I added a function to the WordValues assembly which will take a list of words with values, 3 | and a desired 'total word value', and find sets of those words with the desired total value. 4 | 5 | It's pretty brute-force, and not necessarily very efficient, but that gives scope for improvement. I defined a recursive function `fit` that takes the words used so far, 6 | the unused words, and the remaining total to make up. It stops when the remaining total is 0, or there are no words left to try. It recurses both using the topmost word 7 | and without using that word. 8 | 9 | ```fsharp 10 | let wordsFromValue (logger : ILogger) (wordValues : (string*int) list) (value : int) : string list list = 11 | let rec fit acc ws t = 12 | match ws, t with 13 | | _ , 0 -> [acc] // That's a solution 14 | | [] , _ -> [] // No more words to try 15 | | (w,v)::rest, _ -> 16 | (if (t < v) then [] else fit (w::acc) ws (t - v)) // Use w and fit the remainder 17 | @ (fit acc rest t) // Also try without using w 18 | 19 | Log.info logger (LogEvent.Create("wordsFromValue seeking {value}", value)) 20 | 21 | let result = 22 | fit [] wordValues value 23 | |> List.filter (not << List.isEmpty) 24 | 25 | Log.info logger (LogEvent.Create("wordsFromValue got {count} results", result.Length)) 26 | 27 | result 28 | ``` 29 | 30 | I also added some tests, including a property-based test which needed a custom generator for the wordValues list. I found the FsCheck way of doing this quite 31 | 'fiddly', and might try again using [Hedgehog](https://github.com/hedgehogqa/fsharp-hedgehog) instead. 32 | ```fsharp 33 | type WordList = 34 | | WordList of (string*int) list with 35 | static member Generator = 36 | let genWord = 37 | Arb.generate 38 | |> Gen.filter (Char.IsLetter) 39 | |> Gen.arrayOf 40 | |> Gen.map String 41 | 42 | genWord 43 | |> Gen.map (fun w -> w, (Calculate.wordValue w).Value) 44 | |> Gen.filter (fun (w, v) -> v > 0) 45 | |> Gen.listOf 46 | |> Gen.map WordList 47 | |> Arb.fromGen 48 | 49 | [ |] )>] 50 | let ``wordsFromValue matches wordValue`` (WordList wordList) (PositiveInt target) = 51 | Calculate.wordsFromValue wordList target 52 | |> List.forall (fun words -> (Calculate.wordValue (String.concat " " words)).Value = target) 53 | ``` 54 | 55 | The `WordList` discriminated union can be deconstructed in the parameter declaration on `wordsFromValue matches wordValue`. 56 | 57 | It has a static member function which generates words (sequences of letters), calculates their value, and removes any zeros (from empty string etc). 58 | 59 | FsCheck will use that generator due to the `Arbitrary=[| typeof |]` property on the `Property` attribute. -------------------------------------------------------------------------------- /22 Property-based tests in Hedgehog.md: -------------------------------------------------------------------------------- 1 | ## Converting property-based tests to Hedgehog 2 | I'd used [FsCheck](https://github.com/fscheck/FsCheck) and [Hedgehog](https://github.com/hedgehogqa/fsharp-hedgehog) in the past, and I'd found Hedgehog 3 | to be more 'friendly', and produce better failing cases using shrinking, but FsCheck seemed faster. I'd started this project with FsCheck to see if I liked 4 | it better now, but makng and using custom generators still wasn't to my liking, so I switched to Hedgehog. 5 | 6 | I changed the package references 7 | ```cmd 8 | dotnet paket remove FsCheck.Xunit --project WordValues.Tests 9 | dotnet paket add Hedgehog --project WordValues.Tests 10 | dotnet restore 11 | ``` 12 | The generator for word lists went from 13 | ```fsharp 14 | type WordList = 15 | | WordList of (string*int) list with 16 | static member Generator = 17 | let genWord = 18 | Arb.generate 19 | |> Gen.filter (Char.IsLetter) 20 | |> Gen.arrayOf 21 | |> Gen.map String 22 | 23 | genWord 24 | |> Gen.map (fun w -> w, (Calculate.wordValue w).Value) 25 | |> Gen.filter (fun (w, v) -> v > 0) 26 | |> Gen.listOf 27 | |> Gen.map WordList 28 | |> Arb.fromGen 29 | ``` 30 | to 31 | ```fsharp 32 | module Gen = 33 | let wordList = 34 | Gen.string (Range.linear 1 20) Gen.alpha 35 | |> Gen.map (fun w -> w, (Calculate.wordValue w).Value) 36 | |> Gen.list (Range.linear 0 100) 37 | ``` 38 | I changed the FsCheck.Xunit `Property` attributes to straight Xunit `Fact` attributes. 39 | Then the tests could be done using the `property` computation expression, and the checking was done with Unquote. 40 | ```diff 41 | +module Gen = 42 | + let nonNullString = 43 | + Gen.string (Range.linear 0 100) (Gen.char Char.MinValue Char.MaxValue) 44 | + 45 | -[] 46 | +[] 47 | -let ``Value of text is below maximum value`` (nnstr : NonNull) = 48 | +let ``Value of text is below maximum value`` () = 49 | + property { 50 | - let str = nnstr.Get 51 | + let! str = Gen.nonNullString 52 | - (Calculate.wordValue str).Value <= 26 * str.Length[] 53 | + test <@ (Calculate.wordValue str).Value <= 26 * str.Length @> 54 | + } |> Property.check 55 | ``` 56 | This showed up a test failure, nicely described in the Test Explorer along with how to reproduce it: 57 | ``` 58 | System.Exception : *** Failed! Falsifiable (after 1 test and 15 shrinks): 59 | "?" 60 | Xunit.Sdk.TrueException: 61 | 62 | (Calculate.wordValue str).Value <= 26 * str.Length 63 | (Calculate.wordValue "?").Value <= 26 * "?".Length 64 | { Value = 106 65 | Warning = None }.Value <= 26 * 1 66 | 106 <= 26 67 | false 68 | 69 | Expected: True 70 | Actual: False 71 | at WordValues.Tests.TestCalculate.Value of text is below maximum value@71-1.Invoke(String _arg1) in C:\git\EverythingAsCodeFSharp\WordValues.Tests\TestCalculate.fs:line 71 72 | at Hedgehog.Property.prepend@115-1.Invoke(Unit _arg1) 73 | This failure can be reproduced by running: 74 | > Property.recheck (1 : Size) ({ Value = 1298872065959223496UL; Gamma = 772578873708680621UL }) 75 | ``` 76 | I changed the `Property.check` to `Property.recheck (1 : Size) ({ Value = 1298872065959223496UL; Gamma = 772578873708680621UL })` and ran under the debugger, 77 | it seems that the wordValue function was happy to assign a value to the word '搴', which is not in the range 'A'-'Z' or 'a'-'z'. 78 | 79 | A second failure occurred in the test that `Warning contains non-letters`, because the characters 'χ' and 'ḳ' were upper-cased to 'Χ' and 'Ḳ' in the warning message. 80 | 81 | It was while chasing down these odd cases with non-Ascii characters that I realised something about these property-based tests. 82 | These `Property.recheck` calls could be added to the tests in addition to the `Property.check` to ensure that a regression 83 | does not occur (assuming that nothing changes in the generators).This would help ensure that after a bug found by the test is fixed, it isn't accidentally re-introduced later. 84 | 85 | I added this helper 86 | ```fsharp 87 | module Property = 88 | let regressionTest size seed prop = 89 | Property.recheck size seed prop 90 | prop 91 | ``` 92 | which returns the property being tested, so that it can be piped into `Property.check` as normal. 93 | ```fsharp 94 | [] 95 | let ``Value of text is same as value of lower case`` () = 96 | property { 97 | let! str = Gen.nonNullString 98 | test <@ (Calculate.wordValue str).Value = (Calculate.wordValue (str.ToLower())).Value @> 99 | } 100 | |> Property.regressionTest 91 { Value = 9535703340393401501UL; Gamma = 8182104926013755423UL } 101 | |> Property.check 102 | ``` -------------------------------------------------------------------------------- /23 Bit-Rot, Parcel and Func.md: -------------------------------------------------------------------------------- 1 | ## Bit-rot, Parcel and Func 2 | 3 | Since the last time I worked in this repo, I'd had to reinstall my dev environment, 4 | certainly one of the best ways to find the assumptions in the build process. 5 | In this case it was a dependency on a Python install. 6 | 7 | ### Parcel 8 | 9 | The `parcel-bundler` package used some Python code, and I didn't have a Python installation on my path, 10 | so `yarn` couldn't install the dependencies. 11 | 12 | Version 2 of the package (now named just `parcel`) is much improved and doesn't require Python, 13 | so I updated the JS projects to use the new version. 14 | 15 | The first shortcoming was that the `--out-file` option was not supported, so I followed the advice in 16 | [the GitHub issue](https://github.com/parcel-bundler/parcel/issues/7960) to use the `parcel-namer-rewrite` plugin. 17 | 18 | (I also had to fiddle with the `targets` section a bit to get a js file that Azure Func liked). 19 | 20 | ### Azure `func` 21 | 22 | There was a change to the Azure `func` local hosting which required changes in the project. 23 | 24 | `func start` produced an error: 25 | 26 | > Microsoft.Azure.WebJobs.Script: Referenced bundle Microsoft.Azure.Functions.ExtensionBundle of version 1.8.1 does not meet the required minimum version of 2.6.1. Update your extension bundle reference in host.json to reference 2.6.1 or later. For more information see https://aka.ms/func-min-bundle-versions. 27 | 28 | But a bit of issue-searching in the GitHub repo [found the fix](https://github.com/Azure/Azure-Functions/issues/1987#issuecomment-952420935) for `host.json` 29 | 30 | ```diff 31 | { 32 | "version": "2.0", 33 | "extensionBundle": { 34 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 35 | - "version": "[1.*, 2.0.0)" 36 | + "version": "[2.*, 3.0.0)" 37 | } 38 | } 39 | ``` 40 | 41 | ### Deployment 42 | I remembered I had to set the environment variables to find the Pulumi stacks in AWS Blob storage: 43 | - `PULUMI_CONFIG_PASSPHRASE` 44 | - `AWS_REGION` 45 | - `AWS_ACCESS_KEY_ID` 46 | - `AWS_SECRET_ACCESS_KEY` 47 | And I used the fake build target to make sure everything was built & published before attempting to deploy it. 48 | 49 | ```cmd 50 | dotnet fake build -f build.fsx -t PulumiDeployAzure 51 | ``` 52 | 53 | But it turned out that I hadn't updated the build script when I changed the stack names in 'episode' 18! 54 | 55 | Once that was fixed, it built and deployed successfully. I waited a few minutes to make sure it had propagated in Azure, and then ran the tests: 56 | 57 | ```cmd 58 | dotnet fake build -f build.fsx -t DeployedTest 59 | ``` 60 | 61 | Happily, all the tests passed. 62 | 63 | ### Deployment II : AWS 64 | Because I'd changed the packaging of the AWS Javascript Lambda too, I tried a deployment to AWS. 65 | 66 | ```cmd 67 | dotnet fake build -f build.fsx -t PulumiDeployAws 68 | ``` 69 | 70 | And... it failed, because the build script attempted to deploy to AWS from the Azure Pulumi project. 71 | It was at this point I wondered what past-me was playing at. 72 | 73 | Once that was fixed and the AWS deployment worked, I re-ran the deployed function tests to check everything was OK with the AWS JS Lambda. 74 | 75 | ```cmd 76 | dotnet fake build -f build.fsx -t DeployedTest 77 | ``` 78 | 79 | Success! -------------------------------------------------------------------------------- /AwsConfig/AwsConfig.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /AwsConfig/Program.fs: -------------------------------------------------------------------------------- 1 | module Program 2 | 3 | open Pulumi.FSharp 4 | open Pulumi.Aws 5 | 6 | let infra () = 7 | let administrators = 8 | Iam.Group( 9 | "administrators", 10 | Iam.GroupArgs( 11 | Name = input "Administrators" 12 | ) 13 | ) 14 | 15 | let administratorsPolicy = 16 | Iam.GroupPolicyAttachment( 17 | "administratorsPolicy", 18 | Iam.GroupPolicyAttachmentArgs( 19 | Group = io administrators.Name, 20 | PolicyArn = input "arn:aws:iam::aws:policy/AdministratorAccess" 21 | ) 22 | ) 23 | 24 | let admin = 25 | Iam.User( 26 | "adminUser", 27 | Iam.UserArgs( 28 | Name = input "admin" 29 | ) 30 | ) 31 | 32 | let adminGroupMemberships = 33 | Iam.UserGroupMembership( 34 | "adminInAdministrators", 35 | Iam.UserGroupMembershipArgs( 36 | User = io admin.Name, 37 | Groups = inputList [ io administrators.Name ] 38 | ) 39 | ) 40 | 41 | let devops = 42 | Iam.Group( 43 | "devops", 44 | Iam.GroupArgs( 45 | Name = input "DevOps" 46 | ) 47 | ) 48 | 49 | let devopsLambdaPolicy = 50 | Iam.GroupPolicy ( 51 | "devopsLambdaPolicy", 52 | Iam.GroupPolicyArgs ( 53 | Group = io devops.Id, 54 | Policy = input 55 | """{ 56 | "Version": "2012-10-17", 57 | "Statement": [{ 58 | "Effect": "Allow", 59 | "Action": [ 60 | "lambda:GetFunctionConfiguration", 61 | "lambda:UpdateFunctionConfiguration", 62 | "lambda:CreateFunction", 63 | "lambda:DeleteFunction", 64 | "lambda:GetPolicy" 65 | ], 66 | "Resource": "arn:aws:lambda:*:*:*" 67 | }] 68 | }""" 69 | ) 70 | ) 71 | 72 | let devopsLambdaPolicy2 = 73 | Iam.GroupPolicy ( 74 | "devopsLambdaPolicy2", 75 | Iam.GroupPolicyArgs ( 76 | Group = io devops.Id, 77 | Policy = input 78 | """{ 79 | "Version": "2012-10-17", 80 | "Statement": [{ 81 | "Effect": "Allow", 82 | "Action": [ 83 | "lambda:InvokeFunction", 84 | "lambda:GetFunction", 85 | "lambda:UpdateFunctionCode", 86 | "lambda:ListVersionsByFunction", 87 | "lambda:GetFunctionCodeSigningConfig", 88 | "lambda:AddPermission", 89 | "lambda:RemovePermission" 90 | ], 91 | "Resource": "arn:aws:lambda:*:*:*:*" 92 | }] 93 | }""" 94 | ) 95 | ) 96 | 97 | let devopsS3Policy = 98 | Iam.GroupPolicy ( 99 | "devopsS3Policy", 100 | Iam.GroupPolicyArgs ( 101 | Group = io devops.Id, 102 | Policy = input 103 | """{ 104 | "Version": "2012-10-17", 105 | "Statement": [{ 106 | "Effect": "Allow", 107 | "Action": [ 108 | "s3:*" 109 | ], 110 | "Resource": "*" 111 | }] 112 | }""" 113 | ) 114 | ) 115 | 116 | let devopsIamPolicy = 117 | Iam.GroupPolicy ( 118 | "devopsIamPolicy", 119 | Iam.GroupPolicyArgs ( 120 | Group = io devops.Id, 121 | Policy = input 122 | """{ 123 | "Version": "2012-10-17", 124 | "Statement": [{ 125 | "Effect": "Allow", 126 | "Action": [ 127 | "iam:ListRoles", 128 | "iam:ListPolicies", 129 | "iam:GetRole", 130 | "iam:CreateRole", 131 | "iam:AttachRolePolicy", 132 | "iam:PassRole", 133 | "iam:ListRolePolicies", 134 | "iam:ListAttachedRolePolicies", 135 | "iam:GetUser", 136 | "iam:CreateServiceLinkedRole" 137 | ], 138 | "Resource": "arn:aws:iam::*:*" 139 | }] 140 | }""" 141 | ) 142 | ) 143 | 144 | let devopsGatewayPolicy = 145 | Iam.GroupPolicy ( 146 | "devopsGatewayPolicy", 147 | Iam.GroupPolicyArgs ( 148 | Group = io devops.Id, 149 | Policy = input 150 | """{ 151 | "Version": "2012-10-17", 152 | "Statement": [{ 153 | "Effect": "Allow", 154 | "Action": [ 155 | "apigateway:GET", 156 | "apigateway:POST", 157 | "apigateway:PATCH", 158 | "apigateway:PUT", 159 | "apigateway:DELETE", 160 | "apigateway:UpdateRestApiPolicy" 161 | ], 162 | "Resource": "arn:aws:apigateway:*::*" 163 | }] 164 | }""" 165 | ) 166 | ) 167 | 168 | let deploy = 169 | Iam.User( 170 | "deploy", 171 | Iam.UserArgs( 172 | Name = input "deploy") 173 | ) 174 | 175 | let deployGroupMemberships = 176 | Iam.UserGroupMembership( 177 | "deployInDevops", 178 | Iam.UserGroupMembershipArgs( 179 | User = io deploy.Name, 180 | Groups = inputList [ io devops.Name ] 181 | ) 182 | ) 183 | 184 | let deployAccess = 185 | Iam.AccessKey( 186 | "deployKey", 187 | Iam.AccessKeyArgs( 188 | User = io deploy.Name 189 | ) 190 | ) 191 | 192 | let deploymentStateBucket = 193 | S3.Bucket( 194 | "deploymentState", 195 | S3.BucketArgs() 196 | ) 197 | 198 | let deploymentStateAccess = 199 | S3.BucketPublicAccessBlock( 200 | "deploymentStateAccess", 201 | S3.BucketPublicAccessBlockArgs( 202 | Bucket = io deploymentStateBucket.Id, 203 | BlockPublicAcls = input true, 204 | BlockPublicPolicy = input true, 205 | RestrictPublicBuckets = input true, 206 | IgnorePublicAcls = input true 207 | ) 208 | ) 209 | 210 | let backendStateRoot = 211 | deploymentStateBucket.BucketName 212 | |> Outputs.apply (fun bn -> $"s3://{bn}") 213 | 214 | dict [ 215 | "deploy.AWS_ACCESS_KEY_ID", deployAccess.Id :> obj 216 | "deploy.AWS_SECRET_ACCESS_KEY", deployAccess.Secret :> obj 217 | "backendStateRoot", backendStateRoot :> obj 218 | ] 219 | 220 | [] 221 | let main _ = 222 | Deployment.run infra 223 | -------------------------------------------------------------------------------- /AwsConfig/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: AwsConfig 2 | runtime: dotnet 3 | description: Configure the AWS instance 4 | -------------------------------------------------------------------------------- /AwsConfig/paket.references: -------------------------------------------------------------------------------- 1 | Pulumi.FSharp 2 | Pulumi.Aws 3 | FSharp.Core -------------------------------------------------------------------------------- /Deployment.Aws/CloudTrail.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget:FSharp.Data" 2 | open FSharp.Data 3 | 4 | type EventHistory = JsonProvider< @"c:\users\mark\downloads\event_history.json"> 5 | 6 | let events = EventHistory.Load @"c:\users\mark\downloads\event_history.json" 7 | 8 | let accessDenied = 9 | events.Records 10 | |> Seq.filter (fun r -> r.ErrorCode = Some "AccessDenied") 11 | |> Seq.distinctBy (fun r -> r.ErrorMessage) 12 | 13 | for record in accessDenied do 14 | printfn $"{record.ErrorMessage}" 15 | -------------------------------------------------------------------------------- /Deployment.Aws/Deployment.Aws.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Deployment.Aws/Program.fs: -------------------------------------------------------------------------------- 1 | module Program 2 | 3 | open System.IO 4 | open FSharp.Control.Tasks 5 | 6 | open Pulumi 7 | open Pulumi.FSharp 8 | open Pulumi.Aws 9 | open PulumiExtras.Core 10 | open PulumiExtras.Aws 11 | 12 | let parentFolder = DirectoryInfo(__SOURCE_DIRECTORY__).Parent.FullName 13 | 14 | let publishFile = 15 | Path.Combine(parentFolder, "WordValues.Aws", "bin", "Release", "net5.0", "linux-x64", "publish.zip") 16 | 17 | let jsPublishFile = 18 | Path.Combine(parentFolder, "WordValues.Aws.JS", "publish.zip") 19 | 20 | let infra () = 21 | let lambdaRole = 22 | Iam.Role ( 23 | "lambdaRole", 24 | Iam.RoleArgs( 25 | AssumeRolePolicy = input 26 | """{ 27 | "Version": "2012-10-17", 28 | "Statement": [ 29 | { 30 | "Action": "sts:AssumeRole", 31 | "Principal": { 32 | "Service": "lambda.amazonaws.com" 33 | }, 34 | "Effect": "Allow", 35 | "Sid": "" 36 | } 37 | ] 38 | }""" 39 | ) 40 | ) 41 | 42 | let region = Config.Region 43 | let accountId = Config.getAccountId () 44 | 45 | let codeBucket = 46 | S3.Bucket( 47 | "codeBucket", 48 | S3.BucketArgs() 49 | ) 50 | 51 | let endpoint = 52 | let lambdaCode = S3.uploadCode "lambdaCode" codeBucket "lambdaCode.zip" publishFile 53 | 54 | let lambda = 55 | Lambda.Function( 56 | "wordLambda", 57 | Lambda.FunctionArgs( 58 | Runtime = inputUnion2Of2 Lambda.Runtime.Custom, 59 | Handler = input "bootstrap::WordValues.Aws.Function::functionHandler", // TODO - remove name dependency 60 | Role = io lambdaRole.Arn, 61 | S3Bucket = io lambdaCode.Blob.Bucket, 62 | S3Key = io lambdaCode.Blob.Key, 63 | SourceCodeHash = input lambdaCode.Hash 64 | ) 65 | ) 66 | 67 | let restApi = 68 | ApiGateway.RestApi( 69 | "wordGateway", 70 | ApiGateway.RestApiArgs( 71 | Name = input "WordGateway", 72 | Description = input "API Gateway for the WordValue function", 73 | Policy = input ApiGateway.defaultRestApiPolicy 74 | ) 75 | ) 76 | 77 | let stageAndDeployment = 78 | restApi 79 | |> ApiGateway.proxyResource "wordResource" 80 | |> ApiGateway.anonymousAnyMethod "wordMethod" 81 | |> ApiGateway.awsProxyIntegration "wordIntegration" lambda 82 | |> ApiGateway.deployment "wordDeployment" "WordValue API deployment" 83 | |> ApiGateway.stage "wordStage" 84 | 85 | let permission = Lambda.apiPermission "wordPermission" region accountId restApi lambda 86 | let proxyPermission = Lambda.proxyPermission "wordProxyPermission" {| stageAndDeployment with Lambda = lambda |} 87 | 88 | let endpoint = 89 | (restApi.Id, stageAndDeployment.Stage.StageName) 90 | ||> Output.map2 (fun gwId stageName -> $"https://%s{gwId}.execute-api.%s{region}.amazonaws.com/%s{stageName}/wordvalue") // The last component is ingored 91 | 92 | endpoint 93 | 94 | let jsEndpoint = 95 | let lambdaCode = S3.uploadCode "jsLambdaCode" codeBucket "jsLambdaCode.zip" jsPublishFile 96 | 97 | let lambda = 98 | Lambda.Function( 99 | "wordJsLambda", 100 | Lambda.FunctionArgs( 101 | Runtime = inputUnion2Of2 Lambda.Runtime.NodeJS14dX, 102 | Handler = input "index.functionHandler", 103 | Role = io lambdaRole.Arn, 104 | S3Bucket = io lambdaCode.Blob.Bucket, 105 | S3Key = io lambdaCode.Blob.Key, 106 | SourceCodeHash = input lambdaCode.Hash 107 | ) 108 | ) 109 | 110 | let restApi = 111 | ApiGateway.RestApi( 112 | "wordJsGateway", 113 | ApiGateway.RestApiArgs( 114 | Name = input "WordJSGateway", 115 | Description = input "API Gateway for the WordValue JavaScript function", 116 | Policy = input ApiGateway.defaultRestApiPolicy 117 | ) 118 | ) 119 | 120 | let stageAndDeployment = 121 | restApi 122 | |> ApiGateway.proxyResource "wordJsResource" 123 | |> ApiGateway.anonymousAnyMethod "wordJsMethod" 124 | |> ApiGateway.awsProxyIntegration "wordJsIntegration" lambda 125 | |> ApiGateway.deployment "wordJsDeployment" "WordValue JS API deployment" 126 | |> ApiGateway.stage "wordJsStage" 127 | 128 | let proxyPermission = Lambda.proxyPermission "wordJsProxyPermission" {| stageAndDeployment with Lambda = lambda |} 129 | let permission = Lambda.apiPermission "wordJsPermission" region accountId restApi lambda 130 | 131 | let endpoint = 132 | (restApi.Id, stageAndDeployment.Stage.StageName) 133 | ||> Output.map2 (fun gwId stageName -> $"https://%s{gwId}.execute-api.%s{region}.amazonaws.com/%s{stageName}/wordvalue") // The last component is ingored 134 | 135 | endpoint 136 | 137 | dict [ 138 | "endpoint", endpoint :> obj 139 | "jsEndpoint", jsEndpoint :> obj 140 | ] 141 | 142 | 143 | [] 144 | let main _ = 145 | Deployment.run infra 146 | -------------------------------------------------------------------------------- /Deployment.Aws/Pulumi.Extras.Aws.fs: -------------------------------------------------------------------------------- 1 | module PulumiExtras.Aws 2 | 3 | open FSharp.Control.Tasks 4 | 5 | open Pulumi 6 | open Pulumi.FSharp 7 | open Pulumi.Aws 8 | 9 | open PulumiExtras.Core 10 | 11 | [] 12 | module Config = 13 | let getAccountId () = 14 | task { 15 | let! identity = Pulumi.Aws.GetCallerIdentity.InvokeAsync() 16 | return identity.AccountId 17 | } 18 | |> Output.getAsync 19 | 20 | [] 21 | module File = 22 | let assetOrArchive path = 23 | FileArchive path :> Archive :> AssetOrArchive 24 | 25 | [] 26 | module S3 = 27 | let uploadCode name (bucket : S3.Bucket) blobName zipFilePath = 28 | let hash = File.base64SHA256 zipFilePath 29 | 30 | let blob = 31 | S3.BucketObject( 32 | name, 33 | S3.BucketObjectArgs( 34 | Bucket = io bucket.BucketName, 35 | Key = input blobName, 36 | Source = input (File.assetOrArchive zipFilePath) 37 | ) 38 | ) 39 | 40 | {| Hash = hash; Blob = blob |} 41 | 42 | module ApiGateway = 43 | open Pulumi.Aws.ApiGateway 44 | 45 | let defaultRestApiPolicy = 46 | """{ 47 | "Version": "2012-10-17", 48 | "Statement": [ 49 | { 50 | "Action": "sts:AssumeRole", 51 | "Principal": { 52 | "Service": "lambda.amazonaws.com" 53 | }, 54 | "Effect": "Allow", 55 | "Sid": "" 56 | }, 57 | { 58 | "Action": "execute-api:Invoke", 59 | "Resource": "*", 60 | "Principal": "*", 61 | "Effect": "Allow", 62 | "Sid": "" 63 | } 64 | ] 65 | }""" 66 | 67 | let proxyResource name (restApi : RestApi) = 68 | let resource = 69 | ApiGateway.Resource( 70 | name, 71 | ApiGateway.ResourceArgs( 72 | RestApi = io restApi.Id, 73 | PathPart = input "{proxy+}", 74 | ParentId = io restApi.RootResourceId 75 | ) 76 | ) 77 | {| Resource = resource; RestApi = restApi |} 78 | 79 | let anonymousAnyMethod name (ctx : {| Resource : Resource; RestApi : RestApi |}) = 80 | let method = 81 | ApiGateway.Method( 82 | name, 83 | ApiGateway.MethodArgs( 84 | HttpMethod = input "ANY", 85 | Authorization = input "NONE", 86 | RestApi = io ctx.RestApi.Id, 87 | ResourceId = io ctx.Resource.Id 88 | ) 89 | ) 90 | {| ctx with Method = method |} 91 | 92 | let awsProxyIntegration name (lambda : Lambda.Function) (ctx : {| Resource : Resource; RestApi : RestApi; Method : Method |}) = 93 | let integration = 94 | ApiGateway.Integration( 95 | name, 96 | ApiGateway.IntegrationArgs( 97 | HttpMethod = input "ANY", 98 | IntegrationHttpMethod = input "POST", 99 | ResourceId = io ctx.Resource.Id, 100 | RestApi = io ctx.RestApi.Id, 101 | Type = input "AWS_PROXY", 102 | Uri = io lambda.InvokeArn 103 | ), 104 | CustomResourceOptions( 105 | DependsOn = InputList.ofSeq [ ctx.Method ] 106 | ) 107 | ) 108 | {| ctx with Integration = integration |} 109 | 110 | let deployment name description (ctx : {| Integration : Integration; Resource : Resource; RestApi : RestApi; Method : Method |}) = 111 | let deployment = 112 | ApiGateway.Deployment( 113 | name, 114 | ApiGateway.DeploymentArgs( 115 | Description = input description, 116 | RestApi = io ctx.RestApi.Id 117 | ), 118 | CustomResourceOptions( 119 | DependsOn = InputList.ofSeq [ ctx.Resource; ctx.Method; ctx.Integration ] 120 | ) 121 | ) 122 | {| RestApi = ctx.RestApi; Deployment = deployment |} 123 | 124 | let stage name (ctx : {| Deployment : Deployment; RestApi : RestApi |}) = 125 | let stage = 126 | ApiGateway.Stage( 127 | name, 128 | ApiGateway.StageArgs( 129 | Deployment = io ctx.Deployment.Id, 130 | RestApi = io ctx.RestApi.Id, 131 | StageName = input "dev" 132 | ) 133 | ) 134 | {| Stage = stage; Deployment = ctx.Deployment |} 135 | 136 | module Lambda = 137 | let apiPermission name region accountId (restApi : ApiGateway.RestApi) (lambda : Lambda.Function) = 138 | let executionArn = 139 | (accountId, restApi.Id) 140 | ||> Output.map2 (fun accId gwId -> $"arn:aws:execute-api:%s{region}:%s{accId}:%s{gwId}/*/*/*") 141 | 142 | Lambda.Permission( 143 | name, 144 | Lambda.PermissionArgs( 145 | Action = input "lambda:InvokeFunction", 146 | Function = io lambda.Name, 147 | Principal = input "apigateway.amazonaws.com", 148 | SourceArn = io executionArn, 149 | StatementIdPrefix = input "lambdaPermission" 150 | ), 151 | CustomResourceOptions( 152 | DeleteBeforeReplace = true 153 | ) 154 | ) 155 | 156 | let proxyPermission name (ctx : {| Stage : ApiGateway.Stage; Deployment : ApiGateway.Deployment; Lambda : Lambda.Function |}) = 157 | let proxyArn = 158 | (ctx.Deployment.ExecutionArn, ctx.Stage.StageName) 159 | ||> Output.map2 (fun execArn stageName -> $"{execArn}{stageName}/*/{{proxy+}}") 160 | 161 | let lambdaProxyPermission = 162 | Lambda.Permission( 163 | name, 164 | Lambda.PermissionArgs( 165 | Action = input "lambda:InvokeFunction", 166 | Function = io ctx.Lambda.Arn, 167 | Principal = input "apigateway.amazonaws.com", 168 | SourceArn = io proxyArn 169 | ) 170 | ) 171 | 172 | lambdaProxyPermission 173 | -------------------------------------------------------------------------------- /Deployment.Aws/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | runtime: dotnet 3 | description: Deploy AWS cloud components 4 | -------------------------------------------------------------------------------- /Deployment.Aws/paket.references: -------------------------------------------------------------------------------- 1 | Pulumi.FSharp 2 | FSharp.Core 3 | Ply 4 | Pulumi.Random 5 | Pulumi.Aws -------------------------------------------------------------------------------- /Deployment.Azure/Deployment.Azure.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Deployment.Azure/Program.fs: -------------------------------------------------------------------------------- 1 | module Program 2 | 3 | open System.IO 4 | 5 | open Pulumi 6 | open Pulumi.FSharp 7 | open Pulumi.AzureNative 8 | 9 | open PulumiExtras.Core 10 | open PulumiExtras.Azure 11 | 12 | let parentFolder = DirectoryInfo(__SOURCE_DIRECTORY__).Parent.FullName 13 | 14 | let publishFolder = 15 | Path.Combine(parentFolder, "WordValues.Azure", "bin", "Release", "net5.0", "publish") 16 | 17 | let publishJSZip = 18 | Path.Combine(parentFolder, "WordValues.Azure.JS", "publish.zip") 19 | 20 | let infra () = 21 | let resourceGroup = Resources.ResourceGroup("functions-rg") 22 | 23 | let storageAccount = 24 | let skuArgs = Storage.Inputs.SkuArgs(Name = inputUnion2Of2 Storage.SkuName.Standard_LRS) 25 | Storage.StorageAccount( 26 | "sa", 27 | Storage.StorageAccountArgs( 28 | ResourceGroupName = io resourceGroup.Name, 29 | Sku = input skuArgs, 30 | Kind = inputUnion2Of2 Storage.Kind.StorageV2 31 | ) 32 | ) 33 | 34 | let storageConnection = Storage.getConnectionString storageAccount resourceGroup 35 | 36 | let appServicePlan = 37 | let skuArgs = Web.Inputs.SkuDescriptionArgs(Tier = input "Dynamic", Name = input "Y1") 38 | 39 | Web.AppServicePlan( 40 | "functions-asp", 41 | Web.AppServicePlanArgs( 42 | ResourceGroupName = io resourceGroup.Name, 43 | Kind = input "FunctionApp", 44 | Sku = input skuArgs 45 | ) 46 | ) 47 | 48 | let container = 49 | Storage.BlobContainer( 50 | "zips-container", 51 | Storage.BlobContainerArgs( 52 | AccountName = io storageAccount.Name, 53 | PublicAccess = input Storage.PublicAccess.None, 54 | ResourceGroupName = io resourceGroup.Name 55 | ) 56 | ) 57 | 58 | let appInsights = 59 | Insights.Component( 60 | "appInsights", 61 | Insights.ComponentArgs( 62 | ApplicationType = inputUnion2Of2 Insights.ApplicationType.Web, 63 | Kind = input "web", 64 | ResourceGroupName = io resourceGroup.Name 65 | ) 66 | ) 67 | 68 | let endpoint = 69 | let codeBlob = Storage.uploadCode "zip" publishFolder storageAccount container resourceGroup 70 | let appName = Random.decorate "app" 71 | 72 | let siteConfig = 73 | Web.Inputs.SiteConfigArgs( 74 | AppSettings = 75 | InputList.ofNamedInputValues [ 76 | ("APPINSIGHTS_INSTRUMENTATIONKEY", io appInsights.InstrumentationKey) 77 | ("AzureWebJobsStorage", io storageConnection) 78 | ("FUNCTIONS_EXTENSION_VERSION", input "~3") 79 | ("FUNCTIONS_WORKER_RUNTIME", input "dotnet-isolated") 80 | ("WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", io storageConnection) 81 | ("WEBSITE_CONTENTSHARE", io appName) 82 | ("WEBSITE_RUN_FROM_PACKAGE", io codeBlob.SignedReadUrl) 83 | ], 84 | NetFrameworkVersion = input "v5.0" 85 | ) 86 | 87 | let appAndEndpoint = Web.createApp "app" appName siteConfig appServicePlan resourceGroup 88 | appAndEndpoint.Endpoint 89 | 90 | let jsEndpoint = 91 | let codeBlob = Storage.uploadCode "jszip" publishJSZip storageAccount container resourceGroup 92 | let appName = Random.decorate "jsapp" 93 | 94 | let siteConfig = 95 | Web.Inputs.SiteConfigArgs( 96 | AppSettings = 97 | InputList.ofNamedInputValues [ 98 | ("APPINSIGHTS_INSTRUMENTATIONKEY", io appInsights.InstrumentationKey) 99 | ("AzureWebJobsStorage", io storageConnection) 100 | ("FUNCTIONS_EXTENSION_VERSION", input "~3") 101 | ("FUNCTIONS_WORKER_RUNTIME", input "node") 102 | ("WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", io storageConnection) 103 | ("WEBSITE_CONTENTSHARE", io appName) 104 | ("WEBSITE_RUN_FROM_PACKAGE", io codeBlob.SignedReadUrl) 105 | ("WEBSITE_NODE_DEFAULT_VERSION", input "~14") 106 | ], 107 | Http20Enabled = input true, 108 | NodeVersion = input "~14" 109 | ) 110 | 111 | let appAndEndpoint = Web.createApp "jsapp" appName siteConfig appServicePlan resourceGroup 112 | appAndEndpoint.Endpoint 113 | 114 | dict [ 115 | "resouceGroup", resourceGroup.Name :> obj 116 | "storageAccount", storageAccount.Name :> obj 117 | "endpoint", endpoint :> obj 118 | "jsEndpoint", jsEndpoint :> obj 119 | ] 120 | 121 | [] 122 | let main _ = 123 | Deployment.run infra 124 | -------------------------------------------------------------------------------- /Deployment.Azure/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | runtime: dotnet 3 | description: Deploy cloud components 4 | -------------------------------------------------------------------------------- /Deployment.Azure/PulumiExtras.Azure.fs: -------------------------------------------------------------------------------- 1 | module PulumiExtras.Azure 2 | 3 | open FSharp.Control.Tasks 4 | 5 | open Pulumi 6 | open Pulumi.FSharp 7 | open Pulumi.AzureNative 8 | 9 | open PulumiExtras.Core 10 | 11 | [] 12 | module InputList = 13 | open Pulumi.AzureNative.Web 14 | let ofNamedInputValues nvs : InputList = 15 | nvs 16 | |> Seq.map (fun (n, v) -> Inputs.NameValuePairArgs(Name = input n, Value = v)) 17 | |> Array.ofSeq 18 | |> InputList.op_Implicit 19 | 20 | [] 21 | module Storage = 22 | open Pulumi.AzureNative.Storage 23 | 24 | let signedBlobReadUrl (blob : Blob) (container : BlobContainer) (account : StorageAccount) (resourceGroup : Resources.ResourceGroup) : Output = 25 | Output.zip4 (blob.Name) (container.Name) (account.Name) (resourceGroup.Name) 26 | |> Output.mapAsync (fun (blobName, containerName, accountName, resourceGroupName) -> 27 | task { 28 | let! blobSAS = 29 | ListStorageAccountServiceSAS.InvokeAsync( 30 | ListStorageAccountServiceSASArgs( 31 | AccountName = accountName, 32 | Protocols = HttpProtocol.Https, 33 | SharedAccessStartTime = "2021-01-01", 34 | SharedAccessExpiryTime = "2030-01-01", 35 | Resource = union2Of2 SignedResource.C, 36 | ResourceGroupName = resourceGroupName, 37 | Permissions = union2Of2 Permissions.R, 38 | CanonicalizedResource = $"/blob/{accountName}/{containerName}", 39 | ContentType = "application/json", 40 | CacheControl = "max-age=5", 41 | ContentDisposition = "inline", 42 | ContentEncoding = "deflate" 43 | ) 44 | ) 45 | return Output.format $"https://{accountName}.blob.core.windows.net/{containerName}/{blobName}?{blobSAS.ServiceSasToken}" 46 | } 47 | ) 48 | |> Output.flatten 49 | 50 | let getConnectionString (account : StorageAccount) (resourceGroup : Resources.ResourceGroup) : Output = 51 | (resourceGroup.Name, account.Name) 52 | ||> Output.zip 53 | |> Output.mapAsync (fun (rgName, saName) -> 54 | task { 55 | let! storageAccountKeys = 56 | ListStorageAccountKeys.InvokeAsync( 57 | ListStorageAccountKeysArgs( 58 | ResourceGroupName = rgName, 59 | AccountName = saName 60 | ) 61 | ) 62 | let primaryStorageKey = storageAccountKeys.Keys.[0].Value |> Output.secret 63 | 64 | return Output.format $"DefaultEndpointsProtocol=https;AccountName={account.Name};AccountKey={primaryStorageKey}" 65 | } 66 | ) 67 | |> Output.flatten 68 | 69 | let uploadCode name filesystemPath (account : StorageAccount) (container : BlobContainer) (resourceGroup : Resources.ResourceGroup) = 70 | let blob = 71 | Storage.Blob( 72 | name, 73 | Storage.BlobArgs( 74 | AccountName = io account.Name, 75 | ContainerName = io container.Name, 76 | ResourceGroupName = io resourceGroup.Name, 77 | Type = input Storage.BlobType.Block, 78 | Source = input (FileArchive filesystemPath :> AssetOrArchive) 79 | ) 80 | ) 81 | 82 | let codeBlobUrl = signedBlobReadUrl blob container account resourceGroup 83 | 84 | {| Blob = blob; SignedReadUrl = codeBlobUrl |} 85 | 86 | module Web = 87 | let createApp name appName siteConfig (appServicePlan : Web.AppServicePlan) (resourceGroup : Resources.ResourceGroup) = 88 | let app = 89 | Web.WebApp( 90 | name, 91 | Web.WebAppArgs( 92 | Name = io appName, 93 | Kind = input "FunctionApp", 94 | ResourceGroupName = io resourceGroup.Name, 95 | ServerFarmId = io appServicePlan.Id, 96 | SiteConfig = input siteConfig 97 | ) 98 | ) 99 | 100 | let endpoint = 101 | Output.format $"https://{app.DefaultHostName}/api/WordValue" // TODO - remove hardcoded 'api' and 'WordValue' 102 | 103 | {| App = app; Endpoint = endpoint |} 104 | -------------------------------------------------------------------------------- /Deployment.Azure/paket.references: -------------------------------------------------------------------------------- 1 | Pulumi.AzureNative 2 | Pulumi.FSharp 3 | FSharp.Core 4 | Ply 5 | Pulumi.Random -------------------------------------------------------------------------------- /Deployment.Tests/Aws.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Aws 2 | 3 | open System 4 | 5 | open Xunit 6 | 7 | open Testing.Apis 8 | open Deployment.Tests 9 | 10 | // TODO - category for slow tests that require cloud function 11 | [] 12 | type TestAwsLambda (stack : AwsPulumiStackInstance) = 13 | inherit TestWordValueEndpoints(fun () -> Uri(stack.GetOutputs().["endpoint"].Value :?> string, UriKind.Absolute)) 14 | interface IClassFixture 15 | -------------------------------------------------------------------------------- /Deployment.Tests/AwsJS.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Aws 2 | 3 | open System 4 | 5 | open Xunit 6 | 7 | open Testing.Apis 8 | open Deployment.Tests 9 | 10 | // TODO - category for slow tests that require cloud function 11 | [] 12 | type TestAwsJSLambda (stack : AwsPulumiStackInstance) = 13 | inherit TestWordValueEndpoints(fun () -> Uri(stack.GetOutputs().["jsEndpoint"].Value :?> string, UriKind.Absolute)) 14 | interface IClassFixture 15 | -------------------------------------------------------------------------------- /Deployment.Tests/AwsPulumiStackInstance.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Aws 2 | 3 | open System.IO 4 | 5 | open Deployment.Tests 6 | 7 | module private Deployment = 8 | let folder = Path.Combine(DirectoryInfo(__SOURCE_DIRECTORY__).Parent.FullName, "Deployment.Aws") 9 | let envVars = [ "PULUMI_CONFIG_PASSPHRASE"; "AWS_REGION"; "AWS_ACCESS_KEY_ID"; "AWS_SECRET_ACCESS_KEY" ] 10 | 11 | type AwsPulumiStackInstance() = 12 | inherit PulumiStack("aws-dev", Deployment.folder, Deployment.envVars) 13 | 14 | -------------------------------------------------------------------------------- /Deployment.Tests/Azure.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Azure 2 | 3 | open System 4 | 5 | open Xunit 6 | 7 | open Testing.Apis 8 | open Deployment.Tests 9 | 10 | // TODO - category for slow tests that require cloud function 11 | [] 12 | type TestAzureFunc (stack : AzurePulumiStackInstance) = 13 | inherit TestWordValueEndpoints(fun () -> Uri(stack.GetOutputs().["endpoint"].Value :?> string, UriKind.Absolute)) 14 | interface IClassFixture 15 | -------------------------------------------------------------------------------- /Deployment.Tests/AzureJS.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Azure 2 | 3 | open System 4 | 5 | open Xunit 6 | 7 | open Testing.Apis 8 | open Deployment.Tests 9 | 10 | // TODO - category for slow tests that require cloud function 11 | [] 12 | type TestAzureJSFunc (stack : AzurePulumiStackInstance) = 13 | inherit TestWordValueEndpoints(fun () -> Uri(stack.GetOutputs().["jsEndpoint"].Value :?> string, UriKind.Absolute)) 14 | interface IClassFixture 15 | -------------------------------------------------------------------------------- /Deployment.Tests/AzurePulumiStackInstance.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests.Azure 2 | 3 | open System.IO 4 | 5 | open Deployment.Tests 6 | 7 | module private Deployment = 8 | let folder = Path.Combine(DirectoryInfo(__SOURCE_DIRECTORY__).Parent.FullName, "Deployment.Azure") 9 | let envVars = [ "PULUMI_CONFIG_PASSPHRASE"; "AWS_REGION"; "AWS_ACCESS_KEY_ID"; "AWS_SECRET_ACCESS_KEY" ] 10 | 11 | type AzurePulumiStackInstance() = 12 | inherit PulumiStack("azure-dev", Deployment.folder, Deployment.envVars) 13 | 14 | 15 | -------------------------------------------------------------------------------- /Deployment.Tests/Deployment.Tests.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | true 6 | Library 7 | 8 | 9 | $(MSBuildProjectDirectory)\deployment.runsettings 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Deployment.Tests/PulumiStack.fs: -------------------------------------------------------------------------------- 1 | namespace Deployment.Tests 2 | 3 | open System 4 | open FSharp.Control.Tasks 5 | 6 | open Pulumi.Automation 7 | 8 | type PulumiStack (stackName, folder, expectedEnvVars) = 9 | let missingEnvVars = 10 | let envVars = Environment.GetEnvironmentVariables() 11 | expectedEnvVars 12 | |> List.filter (not << envVars.Contains) 13 | 14 | let outputs = 15 | task { 16 | if (not <| List.isEmpty missingEnvVars) then 17 | missingEnvVars |> String.concat ", " 18 | |> failwithf "Missing environment variables: %s - set in environment or file specified in project's RunSettingsFilePath property" 19 | 20 | let args = LocalProgramArgs(stackName, folder) 21 | let! stack = LocalWorkspace.SelectStackAsync(args) 22 | let! outputs = stack.GetOutputsAsync() 23 | return outputs 24 | } 25 | 26 | member _.GetOutputs() = 27 | outputs.Result 28 | 29 | module TestCollections = 30 | let [] AzureStack = "Azure Stack Tests" 31 | let [] AwsStack = "Aws Stack Tests" -------------------------------------------------------------------------------- /Deployment.Tests/deployment.runsettings.example: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Deployment.Tests/paket.references: -------------------------------------------------------------------------------- 1 | Microsoft.NET.Test.Sdk 2 | SchlenkR.FsHttp 3 | xunit 4 | xunit.runner.visualstudio 5 | coverlet.collector 6 | FSharp.Core 7 | Pulumi.Automation 8 | Ply 9 | System.Text.Json -------------------------------------------------------------------------------- /MkRepo/MkRepo.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /MkRepo/Program.fs: -------------------------------------------------------------------------------- 1 | module Program 2 | 3 | open Pulumi.FSharp 4 | open Pulumi.Github 5 | 6 | let infra () = 7 | let repo = 8 | Repository( 9 | "EverythingAsCodeFSharp", 10 | RepositoryArgs( 11 | Name = input "EverythingAsCodeFSharp", 12 | Description = input "Cloud projects with devops, deployment and build all done in code. In F#.", 13 | Visibility = input "public", 14 | GitignoreTemplate = input "VisualStudio", 15 | HasIssues = input true 16 | ) 17 | ) 18 | 19 | let defaultBranch = 20 | Pulumi.Github.BranchDefault( 21 | "EverythingAsCodeFSharpDefaultBranch", 22 | BranchDefaultArgs( 23 | Repository = io repo.Name, 24 | Branch = input "main" 25 | ) 26 | ) 27 | 28 | // Export outputs here 29 | dict [ 30 | ("EverythingAsCodeFSharp.Clone", repo.HttpCloneUrl :> obj) 31 | ] 32 | 33 | [] 34 | let main _ = 35 | Deployment.run infra 36 | -------------------------------------------------------------------------------- /MkRepo/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: MkRepo 2 | runtime: dotnet 3 | description: Deploy the repository 4 | -------------------------------------------------------------------------------- /MkRepo/paket.references: -------------------------------------------------------------------------------- 1 | Pulumi.FSharp 2 | Pulumi.Github 3 | FSharp.Core -------------------------------------------------------------------------------- /PulumiExtras.Core/Core.fs: -------------------------------------------------------------------------------- 1 | module PulumiExtras.Core 2 | 3 | open System 4 | open System.Threading.Tasks 5 | 6 | open Pulumi 7 | open Pulumi.FSharp 8 | open Pulumi.Random 9 | open System.Security.Cryptography 10 | open System.IO 11 | 12 | [] 13 | module Union = 14 | let union1Of2 = Union.FromT0 15 | let union2Of2 = Union.FromT1 16 | 17 | [] 18 | module Random = 19 | let decorate name = 20 | RandomId(name + "-id", RandomIdArgs(Prefix = input name, ByteLength = input 4)).Hex 21 | 22 | module Output = 23 | let map = Outputs.apply 24 | let map2 (f : 'a -> 'b -> 'c) (a : Output<'a>) (b : Output<'b>) = 25 | Outputs.pair a b 26 | |> Outputs.apply (fun (a,b) -> f a b) 27 | 28 | let zip = Outputs.pair 29 | let zip3 = Outputs.pair3 30 | let zip4 = Outputs.pair4 31 | 32 | let getAsync (t : Task<'u>) = Output.Create<'u> t 33 | 34 | let mapAsync (f : 't -> Task<'u>) (o : Output<'t>) : Output<'u> = 35 | let func = Func<'t, Task<'u>> f 36 | o.Apply<'u>(func : Func<'t, Task<'u>>) 37 | 38 | let flatten (o : Output>) : Output<'a> = 39 | o.Apply<'a>(id) 40 | 41 | let format = Output.Format 42 | 43 | let secret (s : 'a) : Output<'a> = 44 | Output.CreateSecret<'a>(s) 45 | 46 | module InputList = 47 | let ofSeq xs = 48 | xs |> Seq.map input |> inputList 49 | 50 | module File = 51 | let base64SHA256 filePath = 52 | use sha256 = SHA256.Create() 53 | use stream = File.OpenRead filePath 54 | let hash = sha256.ComputeHash stream 55 | Convert.ToBase64String(hash) 56 | 57 | -------------------------------------------------------------------------------- /PulumiExtras.Core/PulumiExtras.Core.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /PulumiExtras.Core/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Pulumi.FSharp 3 | Pulumi.Random -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Everything As Code 2 | ## (In F#) 3 | This project implements some cloud services, with two main objectives and one main non-objective 4 | ### Objectives 5 | 1. To show how much can be done with code instead of having to use Web UIs or shell prompts. Automation is better than manual steps because there's less to remember and less to get wrong. 6 | 2. To show how to do all these things can be done in F#, and hopefully how simple the code is. 7 | ### Non-objectives 8 | 1. The cloud function is pretty pointless. 9 | ### Documentation 10 | The numbered markdown files contain a description of 'the journey' in adding functionality to the project. 11 | I suspect they're written in a distracting mixture of 'voices', and that the earlier ones have the most detail. 12 | I should probably go over them all and make them a bit less uneven. 13 | ### What constitutes 'Everything'? 14 | Currently: 15 | * Deployment - Pulumi 16 | * Creating a Github Repo from Pulumi 17 | * Azure functions in .net 5 18 | * Paket package manager 19 | * Deploying to Azure from Pulumi 20 | * Unit tests 21 | * Integration tests 22 | * Property-based testing with ~~FsCheck~~Hedgehog 23 | * Build scripts in Fake 24 | * Aws Lambdas in .net 5 25 | * Infrastucture and Deployment to AWS from Pulumi 26 | * Typed processing on json-format logs 27 | * Generating Javascript from F# with Fable 28 | * Javascript Azure Function 29 | * Javascript Aws Lambda 30 | * Github workflows for automated builds 31 | ### Progress 32 | I currently have a TODO list of remaining topics to cover, but it's all dependent on how much free time I have and what other shiny things might come along and distract me. 33 | -------------------------------------------------------------------------------- /Services.Clr/Logging.fs: -------------------------------------------------------------------------------- 1 | namespace Services.Clr 2 | 3 | open Microsoft.Extensions.Logging 4 | 5 | type MicrosoftLogger (logger : Microsoft.Extensions.Logging.ILogger) = 6 | interface Services.ILogger with 7 | member _.Log (level: Services.LogLevel) (event: Services.LogEvent) : unit = 8 | let logLevel = 9 | match level with 10 | | Services.LogLevel.Trace -> LogLevel.Trace 11 | | Services.LogLevel.Debug -> LogLevel.Debug 12 | | Services.LogLevel.Info -> LogLevel.Information 13 | | Services.LogLevel.Warn -> LogLevel.Warning 14 | | Services.LogLevel.Error -> LogLevel.Error 15 | | Services.LogLevel.Critical -> LogLevel.Critical 16 | 17 | logger.Log(logLevel, EventId event.EventId, Unchecked.defaultof, event.Message, event.Params) 18 | -------------------------------------------------------------------------------- /Services.Clr/Services.Clr.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Services.Clr/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Microsoft.Extensions.Logging -------------------------------------------------------------------------------- /Services.JS/Logging.fs: -------------------------------------------------------------------------------- 1 | namespace Services.JS 2 | 3 | open System.Text.RegularExpressions 4 | 5 | #if FABLE_COMPILER 6 | open Thoth.Json 7 | #else 8 | open Thoth.Json.Net 9 | #endif 10 | 11 | type LogEntry = { EventId : int; LogLevel : Services.LogLevel; Category : string; Message : string; State : obj[] } 12 | with 13 | static member Encoder (e : LogEntry) = 14 | let level = 15 | match e.LogLevel with 16 | | Services.LogLevel.Trace -> "Trace" 17 | | Services.LogLevel.Debug -> "Debug" 18 | | Services.LogLevel.Info -> "Information" 19 | | Services.LogLevel.Warn -> "Warning" 20 | | Services.LogLevel.Error -> "Error" 21 | | Services.LogLevel.Critical -> "Critical" 22 | 23 | let namesAndValues = 24 | (Regex.Matches(e.Message, "{([^}]+)}"), e.State) 25 | ||> Seq.map2 (fun m v -> (m.Groups.[1].Value, Encode.Auto.generateEncoder() v)) 26 | |> List.ofSeq 27 | 28 | let replacer = 29 | let map = namesAndValues |> Map.ofList 30 | fun (m : Match) -> map.[m.Groups.[1].Value] |> Encode.toString 0 31 | 32 | let message = 33 | Regex.Replace(e.Message, "{([^}]+)}", replacer) 34 | 35 | let state = 36 | namesAndValues 37 | |> List.append [("Message", Encode.string message); ("{OriginalFormat}", Encode.string e.Message) ] 38 | |> Encode.object 39 | 40 | Encode.object [ 41 | ("EventId", Encode.int e.EventId) 42 | ("LogLevel", Encode.string level) 43 | ("Category", Encode.string e.Category) 44 | ("Message", Encode.string message) 45 | ("State", state) 46 | ] 47 | 48 | module LogEntry = 49 | let toString n e = e |> LogEntry.Encoder |> Encode.toString n 50 | -------------------------------------------------------------------------------- /Services.JS/Services.JS.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Services.JS/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Thoth.Json 3 | Thoth.Json.Net 4 | Fable.Core -------------------------------------------------------------------------------- /Services/Logging.fs: -------------------------------------------------------------------------------- 1 | namespace Services 2 | 3 | open System 4 | 5 | type LogLevel = 6 | | Trace 7 | | Debug 8 | | Info 9 | | Warn 10 | | Error 11 | | Critical 12 | 13 | [] 14 | type LogEvent = { Message : string; EventId : int; Params : obj[] } with 15 | static member Create(message, [] pars) = { Message = message; EventId = 0; Params = pars } 16 | 17 | type ILogger = 18 | abstract Log : LogLevel -> LogEvent -> unit 19 | 20 | module Log = 21 | let info (logger : ILogger) event = 22 | logger.Log Info event 23 | -------------------------------------------------------------------------------- /Services/Services.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Services/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core -------------------------------------------------------------------------------- /Testing.Apis/TestWordValueEndpoints.fs: -------------------------------------------------------------------------------- 1 | namespace Testing.Apis 2 | 3 | open System 4 | open System.Net 5 | open System.Text.Json 6 | 7 | open FsHttp 8 | open FsHttp.Dsl 9 | 10 | open Xunit 11 | 12 | [] 13 | type TestWordValueEndpoints (getEndpointUrl : unit -> Uri) = 14 | [] 15 | member _.``WordValue with no query parameter returns an error`` () = 16 | let endpoint = getEndpointUrl() 17 | let testUri = endpoint 18 | 19 | let response = 20 | get testUri.AbsoluteUri 21 | |> Request.send 22 | 23 | Assert.Equal(HttpStatusCode.BadRequest, response.statusCode) 24 | Assert.Equal("Required query parameter 'word' was missing", response |> Response.toText) 25 | 26 | [] 27 | member _.``WordValue with no 'word' query parameter returns an error`` () = 28 | let endpoint = getEndpointUrl() 29 | let testUri = Uri(endpoint, "?spoons=3") 30 | 31 | let response = 32 | get testUri.AbsoluteUri 33 | |> Request.send 34 | 35 | Assert.Equal(HttpStatusCode.BadRequest, response.statusCode) 36 | Assert.Equal("Required query parameter 'word' was missing", response |> Response.toText) 37 | 38 | 39 | [] 40 | member _.``WordValue returns the correct message`` () = 41 | let endpoint = getEndpointUrl() 42 | let testUri = Uri(endpoint, "?word=Hello") 43 | 44 | let response = 45 | get testUri.AbsoluteUri 46 | |> Request.send 47 | 48 | Assert.Equal(HttpStatusCode.OK, response.statusCode) 49 | Assert.Equal("""{"Value":52}""", response |> Response.toText) 50 | 51 | [] 52 | member _.``WordValue returns warnings for non-letters`` () = 53 | let endpoint = getEndpointUrl() 54 | let testUri = Uri(endpoint, "?word=" + Uri.encodeUrlParam "Hello 123") 55 | 56 | let response = 57 | get testUri.AbsoluteUri 58 | |> Request.send 59 | 60 | Assert.Equal(HttpStatusCode.OK, response.statusCode) 61 | 62 | let result = response |> Response.toText |> JsonDocument.Parse 63 | Assert.Equal(52, result.RootElement.GetProperty("Value").GetInt32()) 64 | Assert.Equal("Ignored ' ','1','2','3'", result.RootElement.GetProperty("Warning").GetString()) 65 | -------------------------------------------------------------------------------- /Testing.Apis/Testing.Apis.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Testing.Apis/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | xunit 3 | SchlenkR.FsHttp 4 | Microsoft.NET.Test.Sdk -------------------------------------------------------------------------------- /Testing.AzureLocal/AzureFuncInstance.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues.Azure.Tests 2 | 3 | open System 4 | open System.IO 5 | open System.Net 6 | open System.Diagnostics 7 | open System.Threading 8 | open System.Net.Http 9 | open System.Net.Sockets 10 | open FSharp.Quotations.Patterns 11 | 12 | open Xunit 13 | 14 | module Assert = 15 | let Fail message = 16 | Assert.True(false, message) 17 | Unchecked.defaultof<_> // Unreachable code, here to make conditional branches have matching return types 18 | 19 | module Path = 20 | let replaceLast (find, replace) (path : string) = 21 | let components = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) 22 | let i = components |> Array.findIndexBack ((=) find) 23 | components.[i] <- replace 24 | Path.Combine(components) 25 | 26 | type AzureFuncConnection = 27 | { BaseUri : Uri } 28 | 29 | type AzureFuncInstance (folder, port, ?extraFuncExeParams) = 30 | let extraFuncExeParams = defaultArg extraFuncExeParams "" 31 | let timeoutSeconds = 30 32 | 33 | let canConnectToPort port = 34 | try 35 | use c = new TcpClient("127.0.0.1", port) 36 | true 37 | with | :? SocketException -> false 38 | 39 | let waitForPort (proc : Process) = 40 | let sw = Stopwatch() 41 | sw.Start() 42 | let mutable portFound = false 43 | while (sw.ElapsedMilliseconds < int64 (timeoutSeconds * 1000)) && (not portFound) && (not proc.HasExited) do 44 | Thread.Sleep(TimeSpan.FromSeconds 1.) 45 | portFound <- canConnectToPort port 46 | 47 | if proc.HasExited then 48 | Error $"func.exe has exited with error code %d{proc.ExitCode}" 49 | elif portFound then 50 | Ok { BaseUri = Uri($"http://localhost:%d{port}", UriKind.Absolute) } 51 | else 52 | Error $"func.exe did not open port %d{port} within %d{timeoutSeconds} seconds" 53 | 54 | // TODO - Capture stdout/stderr for diagnostic 55 | let startInfo = 56 | ProcessStartInfo( 57 | FileName = "func.exe", 58 | WorkingDirectory = folder, 59 | UseShellExecute = false, 60 | Arguments = $"start %s{extraFuncExeParams} --port %d{port} --timeout %d{timeoutSeconds}") 61 | 62 | let proc = 63 | if canConnectToPort port then Assert.Fail $"Port %d{port} already in use" 64 | Process.Start(startInfo) 65 | 66 | let connection = lazy (waitForPort proc) 67 | 68 | new (testType : Type, funcMainMethod, port) = 69 | let folder = 70 | // Assume that the function assembly has been copied to the test's build folder 71 | // and is of the form 72 | // (absolute path to solution)/TestAssemblyName/(bin folder)/FuncAssemblyName.dll 73 | // but was originally 74 | // (absolute path to solution)/FuncAssemblyName/(bin folder)/FuncAssemblyName.dll 75 | // and the calling test class is in 76 | // (absolute path to solution)/TestAssemblyName/(bin folder)/TestAssemblyName.dll 77 | 78 | match funcMainMethod with 79 | | Lambda (a, Call(x,methodInfo,y)) -> 80 | let copiedPath = methodInfo.DeclaringType.Assembly.Location 81 | let funcName = Path.GetFileNameWithoutExtension(copiedPath) 82 | let testsName = Path.GetFileNameWithoutExtension(testType.Assembly.Location) 83 | let originalPath = Path.replaceLast (testsName, funcName) copiedPath 84 | 85 | if File.GetLastWriteTime copiedPath <> File.GetLastWriteTime originalPath then 86 | failwithf "%s does not have the same timestamp as %s" copiedPath originalPath 87 | 88 | Path.GetDirectoryName originalPath 89 | | _ -> invalidArg "mainMethod" "Value should be a quotation of the function assembly's main method" 90 | new AzureFuncInstance(folder, port, "--no-build") 91 | 92 | interface IDisposable with 93 | member _.Dispose() = 94 | try 95 | proc.Kill(entireProcessTree=true) 96 | with 97 | | :? InvalidOperationException -> () 98 | 99 | member _.GetConnection() = 100 | match connection.Value with 101 | | Ok c -> 102 | if proc.HasExited 103 | then Assert.Fail $"func.exe has exited with error code %d{proc.ExitCode}" 104 | else c 105 | | Error msg -> 106 | Assert.Fail msg 107 | 108 | 109 | -------------------------------------------------------------------------------- /Testing.AzureLocal/Testing.AzureLocal.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Testing.AzureLocal/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | xunit 3 | Microsoft.NET.Test.Sdk -------------------------------------------------------------------------------- /Testing.Services/Logging.fs: -------------------------------------------------------------------------------- 1 | namespace Testing.Services 2 | 3 | open System.Text.RegularExpressions 4 | open Services 5 | 6 | open Xunit 7 | 8 | type TestLogger() = 9 | static let markerExpr = Regex("{[^}]+}") 10 | 11 | static member Default = TestLogger() 12 | 13 | interface ILogger with 14 | member this.Log _ event = 15 | let markerCount = 16 | markerExpr.Matches(event.Message).Count 17 | 18 | let paramCount = 19 | event.Params 20 | |> Array.length 21 | 22 | Assert.Equal(markerCount, paramCount) -------------------------------------------------------------------------------- /Testing.Services/Testing.Services.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | true 6 | 3390;$(WarnOn) 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Testing.Services/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | xunit 3 | Microsoft.NET.Test.Sdk -------------------------------------------------------------------------------- /WordValues.Aws.JS/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "namers": [ "parcel-namer-rewrite" ] 4 | } -------------------------------------------------------------------------------- /WordValues.Aws.JS/APIGatewayProxyResponse.fs: -------------------------------------------------------------------------------- 1 | namespace Amazon.Lambda.APIGatewayEvents 2 | 3 | open Fable.Core 4 | 5 | module rec Response = 6 | 7 | type [] Headers = 8 | [] abstract Item: name: string -> string option with get, set 9 | 10 | type [] MultiValueHeaders = 11 | [] abstract Item: name: string -> string seq option with get, set 12 | 13 | /// 14 | /// The response object for Lambda functions handling request from API Gateway proxy 15 | /// http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html 16 | /// 17 | //[DataContract] 18 | [] 19 | type APIGatewayProxyResponse = 20 | /// 21 | /// The HTTP status code for the request 22 | /// 23 | //[DataMember(Name = "statusCode")] 24 | #if NETCOREAPP_3_1 25 | [System.Text.Json.Serialization.JsonPropertyName("statusCode")] 26 | #endif 27 | abstract statusCode : int with get, set 28 | 29 | /// 30 | /// The Http headers return in the response. This collection supports setting single value for the same headers. 31 | /// If both the Headers and MultiValueHeaders collections are set API Gateway will merge the collection 32 | /// before returning back the headers to the caller. 33 | /// 34 | //[DataMember(Name = "headers")] 35 | #if NETCOREAPP_3_1 36 | [System.Text.Json.Serialization.JsonPropertyName("headers")] 37 | #endif 38 | abstract headers : Headers with get, set 39 | 40 | /// 41 | /// The Http headers return in the response. This collection supports setting multiple values for the same headers. 42 | /// If both the Headers and MultiValueHeaders collections are set API Gateway will merge the collection 43 | /// before returning back the headers to the caller. 44 | /// 45 | //[DataMember(Name = "multiValueHeaders")] 46 | #if NETCOREAPP_3_1 47 | [System.Text.Json.Serialization.JsonPropertyName("multiValueHeaders")] 48 | #endif 49 | abstract multiValueHeaders : MultiValueHeaders with get, set 50 | 51 | /// 52 | /// The response body 53 | /// 54 | //[DataMember(Name = "body")] 55 | #if NETCOREAPP_3_1 56 | [System.Text.Json.Serialization.JsonPropertyName("body")] 57 | #endif 58 | abstract body : string with get, set 59 | 60 | /// 61 | /// Flag indicating whether the body should be treated as a base64-encoded string 62 | /// 63 | //[DataMember(Name = "isBase64Encoded")] 64 | #if NETCOREAPP_3_1 65 | [System.Text.Json.Serialization.JsonPropertyName("isBase64Encoded")] 66 | #endif 67 | abstract isBase64Encoded : bool with get, set 68 | -------------------------------------------------------------------------------- /WordValues.Aws.JS/Function.fs: -------------------------------------------------------------------------------- 1 | module WordValues.Aws.JS 2 | 3 | open System.Net 4 | open Thoth.Json 5 | open Fable.Core.JsInterop 6 | 7 | open Amazon.Lambda.APIGatewayEvents.Request 8 | open Amazon.Lambda.APIGatewayEvents.Response 9 | 10 | open WordValues 11 | open Services 12 | 13 | let functionHandler (request : APIGatewayProxyRequest, _) = 14 | let logger = ConsoleLogger ("Function") 15 | 16 | promise { 17 | let wordParam = 18 | request.queryStringParameters 19 | |> Option.bind (fun qsps -> qsps.["word"]) 20 | 21 | let response = createEmpty 22 | response.headers <- createEmpty 23 | 24 | match wordParam with 25 | | Some word -> 26 | let result = Calculate.wordValue logger word 27 | let content = result |> WordValue.Encoder |> Encode.toString 0 28 | 29 | response.statusCode <- int HttpStatusCode.OK 30 | response.headers.["Content-Type"] <- Some "application/json" 31 | response.body <- content 32 | | None -> 33 | response.statusCode <- int HttpStatusCode.BadRequest 34 | response.headers.["Content-Type"] <- Some "text/plain;charset=utf-8" 35 | response.body <- "Required query parameter 'word' was missing" 36 | 37 | return response 38 | } 39 | -------------------------------------------------------------------------------- /WordValues.Aws.JS/Services.fs: -------------------------------------------------------------------------------- 1 | module Services 2 | 3 | open Services.JS 4 | 5 | type ConsoleLogger (category : string) = 6 | interface Services.ILogger with 7 | member _.Log level event = 8 | let logEntry = { EventId = event.EventId; LogLevel = level; Category = category; Message = event.Message; State = event.Params } 9 | 10 | let json = logEntry |> LogEntry.toString 0 11 | System.Console.WriteLine(json.ToString()) 12 | 13 | -------------------------------------------------------------------------------- /WordValues.Aws.JS/WordValues.Aws.JS.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WordValues.Aws.JS/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "Function.fs.js", 3 | "parcel-namer-rewrite": { 4 | "chain": "@parcel/namer-default", 5 | "rules": { 6 | "Function.fs.js": "index.js" 7 | } 8 | }, 9 | "scripts": { 10 | "build": "parcel build" 11 | }, 12 | "devDependencies": { 13 | "parcel": "^2.5.0", 14 | "parcel-namer-rewrite": "^2.0.0-rc.2" 15 | }, 16 | "targets": { 17 | "default": { 18 | "distDir": "./WordValue", 19 | "context": "node", 20 | "outputFormat": "commonjs", 21 | "isLibrary": true, 22 | "optimize": true, 23 | "includeNodeModules": false 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /WordValues.Aws.JS/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Fable.Core 3 | Thoth.Json 4 | Fable.Promise -------------------------------------------------------------------------------- /WordValues.Aws.Tests/Tests.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues.Aws.Tests 2 | 3 | open System.Net 4 | open System.Text.Json 5 | 6 | open Xunit 7 | open WordValues.Aws 8 | open Amazon.Lambda.APIGatewayEvents 9 | 10 | module FunctionTest = 11 | 12 | [] 13 | let ``WordValue with no query parameter returns an error`` () = 14 | let request = APIGatewayProxyRequest() 15 | 16 | let response = Function.functionHandler request 17 | 18 | Assert.Equal(int HttpStatusCode.BadRequest, response.StatusCode) 19 | Assert.Equal("Required query parameter 'word' was missing", response.Body) 20 | 21 | [] 22 | let ``WordValue with no 'word' query parameter returns an error`` () = 23 | let request = APIGatewayProxyRequest(QueryStringParameters = dict["spoons", "3"]) 24 | 25 | let response = Function.functionHandler request 26 | 27 | Assert.Equal(int HttpStatusCode.BadRequest, response.StatusCode) 28 | Assert.Equal("Required query parameter 'word' was missing", response.Body) 29 | 30 | [] 31 | let ``WordValue returns the correct message`` () = 32 | let request = APIGatewayProxyRequest(QueryStringParameters = dict["word", "Hello"]) 33 | 34 | let response = Function.functionHandler request 35 | 36 | Assert.Equal(int HttpStatusCode.OK, response.StatusCode) 37 | Assert.Equal("""{"Value":52}""", response.Body) 38 | 39 | [] 40 | let ``WordValue returns warnings for non-letters`` () = 41 | let request = APIGatewayProxyRequest(QueryStringParameters = dict["word", "Hello 123"]) 42 | 43 | let response = Function.functionHandler request 44 | 45 | Assert.Equal(int HttpStatusCode.OK, response.StatusCode) 46 | 47 | let result = response.Body |> JsonDocument.Parse 48 | Assert.Equal(52, result.RootElement.GetProperty("Value").GetInt32()) 49 | Assert.Equal("Ignored ' ','1','2','3'", result.RootElement.GetProperty("Warning").GetString()) 50 | -------------------------------------------------------------------------------- /WordValues.Aws.Tests/WordValues.Aws.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Library 5 | false 6 | net5.0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /WordValues.Aws.Tests/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Amazon.Lambda.Core 3 | Microsoft.NET.Test.Sdk 4 | Amazon.Lambda.TestUtilities 5 | xunit 6 | xunit.runner.visualstudio 7 | coverlet.collector -------------------------------------------------------------------------------- /WordValues.Aws/Function.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues.Aws 2 | 3 | open System 4 | open System.Net 5 | open Microsoft.Extensions.Logging 6 | open Thoth.Json.Net 7 | 8 | open Amazon.Lambda.Core 9 | open Amazon.Lambda.RuntimeSupport 10 | open Amazon.Lambda.Serialization.SystemTextJson 11 | open Amazon.Lambda.APIGatewayEvents 12 | 13 | open Services.Clr 14 | 15 | open WordValues 16 | 17 | // This project specifies the serializer used to convert Lambda event into .NET classes in the project's main 18 | // main function. This assembly register a serializer for use when the project is being debugged using the 19 | // AWS .NET Mock Lambda Test Tool. 20 | [)>] 21 | () 22 | 23 | module Dictionary = 24 | let tryGet key (dict : System.Collections.Generic.IDictionary<_,_>) = 25 | match dict.TryGetValue key with 26 | | false, _ -> None 27 | | true, v -> Some v 28 | 29 | module Function = 30 | let logger = MicrosoftLogger(LoggerFactory.Create(fun builder -> builder.AddLambdaLogger() |> ignore).CreateLogger("Function")) 31 | 32 | let functionHandler (request : APIGatewayProxyRequest) = 33 | let wordParam = 34 | request.QueryStringParameters 35 | |> Option.ofObj 36 | |> Option.bind (Dictionary.tryGet "word") 37 | 38 | match wordParam with 39 | | Some word -> 40 | let result = Calculate.wordValue logger word 41 | let content = result |> WordValue.Encoder |> Encode.toString 0 42 | 43 | APIGatewayProxyResponse( 44 | StatusCode = int HttpStatusCode.OK, 45 | Headers = dict [ ("Content-Type", "application/json") ], 46 | Body = content 47 | ) 48 | | None -> 49 | APIGatewayProxyResponse( 50 | StatusCode = int HttpStatusCode.BadRequest, 51 | Headers = dict [ ("Content-Type", "text/plain;charset=utf-8") ], 52 | Body = "Required query parameter 'word' was missing" 53 | ) 54 | 55 | [] 56 | let main _args = 57 | 58 | let handler = Func(functionHandler) 59 | use handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler, new DefaultLambdaJsonSerializer()) 60 | use bootstrap = new LambdaBootstrap(handlerWrapper) 61 | 62 | bootstrap.RunAsync().GetAwaiter().GetResult() 63 | 0 -------------------------------------------------------------------------------- /WordValues.Aws/Readme.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Custom Runtime Function Project 2 | 3 | This starter project consists of: 4 | * Function.fs - contains a main function that starts the bootstrap, and a single function handler 5 | * aws-lambda-tools-defaults.json - default argument settings for use with Visual Studio and command line deployment tools for AWS 6 | 7 | You may also have a test project depending on the options selected. 8 | 9 | The generated main function is the entry point for the function's process. The main function wraps the function handler in a wrapper that the bootstrap can work with. Then it instantiates the bootstrap and sets it up to call the function handler each time the AWS Lambda function is invoked. After the set up the bootstrap is started. 10 | 11 | The generated function handler is a simple function accepting a string argument that returns the uppercase equivalent of the input string. Replace the body of this function, and parameters, to suit your needs. 12 | 13 | ## Here are some steps to follow from Visual Studio: 14 | 15 | (Deploying and invoking custom runtime functions is not yet available in Visual Studio) 16 | 17 | ## Here are some steps to follow to get started from the command line: 18 | 19 | Once you have edited your template and code you can deploy your application using the [Amazon.Lambda.Tools Global Tool](https://github.com/aws/aws-extensions-for-dotnet-cli#aws-lambda-amazonlambdatools) from the command line. Version 3.1.4 20 | or later is required to deploy this project. 21 | 22 | Install Amazon.Lambda.Tools Global Tools if not already installed. 23 | ``` 24 | dotnet tool install -g Amazon.Lambda.Tools 25 | ``` 26 | 27 | If already installed check if new version is available. 28 | ``` 29 | dotnet tool update -g Amazon.Lambda.Tools 30 | ``` 31 | 32 | Execute unit tests 33 | ``` 34 | cd "BlueprintBaseName/test/BlueprintBaseName.Tests" 35 | dotnet test 36 | ``` 37 | 38 | Deploy function to AWS Lambda 39 | ``` 40 | cd "BlueprintBaseName/src/BlueprintBaseName" 41 | dotnet lambda deploy-function 42 | ``` 43 | 44 | ## Using AWS .NET Mock Lambda Test Tool 45 | 46 | The AWS .NET Mock Lambda Test Tool can be used with .NET Lambda custom runtimes. When the test tool is used for custom runtime the project 47 | is executed similar to a Lambda managed runtime and the main method is not called. The test tool uses the `function-handler` field in 48 | the `aws-lambda-tools-defaults.json` file to figure out what code to call when executing a function in it. 49 | 50 | To configure the test tool for custom runtimes follow these steps: 51 | 52 | * Ensure the `function-handler` is set in the `aws-lambda-tools-defaults.json` for the method to call. 53 | * There is a JSON serializer registered for test tool to using the `LambdaSerializer` assembly attribute. 54 | * `[)>]` 55 | * Ensure the test tool is installed from NuGet. Below is an example for installing the .NET 5.0 version. 56 | * `dotnet tool install -g Amazon.Lambda.TestTool-5.0` or to update `dotnet tool update -g Amazon.Lambda.TestTool-5.0` 57 | * For Visual Studio edit or add the `Properties\launchSettings.json` to register the test tool as a debug target. 58 | ```json 59 | { 60 | "profiles": { 61 | "Mock Lambda Test Tool": { 62 | "commandName": "Executable", 63 | "commandLineArgs": "--port 5050", 64 | "workingDirectory": ".\\bin\\$(Configuration)\\net5.0", 65 | "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-5.0.exe" 66 | } 67 | } 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /WordValues.Aws/WordValues.Aws.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net5.0 6 | Lambda 7 | bootstrap 8 | 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /WordValues.Aws/aws-lambda-tools-defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "Information": [ 3 | "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", 4 | "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", 5 | "dotnet lambda help", 6 | "All the command line options for the Lambda command can be specified in this file." 7 | ], 8 | "profile": "", 9 | "region": "", 10 | "configuration": "Release", 11 | "function-runtime": "provided", 12 | "function-memory-size": 256, 13 | "function-timeout": 30, 14 | "function-handler": "bootstrap::WordValues.Aws.Function::functionHandler", 15 | "function-name": "WordValues.Aws", 16 | "msbuild-parameters": "--self-contained true" 17 | } -------------------------------------------------------------------------------- /WordValues.Aws/paket.references: -------------------------------------------------------------------------------- 1 | Amazon.Lambda.Core 2 | Amazon.Lambda.RuntimeSupport 3 | Amazon.Lambda.Serialization.SystemTextJson 4 | FSharp.Core 5 | Amazon.Lambda.APIGatewayEvents 6 | System.Text.Json 7 | Amazon.Lambda.Logging.AspNetCore -------------------------------------------------------------------------------- /WordValues.Azure.JS.Tests/Tests.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues.Azure.JS.Tests 2 | 3 | open System 4 | open System.IO 5 | 6 | open Xunit 7 | 8 | open Testing.Apis 9 | open WordValues.Azure.Tests 10 | 11 | module WordValuesAzureJSFunc = 12 | let folder = Path.Combine(DirectoryInfo(__SOURCE_DIRECTORY__).Parent.FullName, "WordValues.Azure.JS") 13 | 14 | type WordValuesAzureFuncInstance() = 15 | inherit AzureFuncInstance(WordValuesAzureJSFunc.folder, 7072) 16 | 17 | type TestAzureFun (func : WordValuesAzureFuncInstance) = 18 | inherit TestWordValueEndpoints(fun () -> Uri(func.GetConnection().BaseUri, "/api/WordValue")) 19 | interface IClassFixture 20 | -------------------------------------------------------------------------------- /WordValues.Azure.JS.Tests/WordValues.Azure.JS.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0 5 | false 6 | Library 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WordValues.Azure.JS.Tests/paket.references: -------------------------------------------------------------------------------- 1 | Microsoft.NET.Test.Sdk 2 | SchlenkR.FsHttp 3 | xunit 4 | xunit.runner.visualstudio 5 | coverlet.collector 6 | FSharp.Core -------------------------------------------------------------------------------- /WordValues.Azure.JS/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "namers": [ "parcel-namer-rewrite" ] 4 | } -------------------------------------------------------------------------------- /WordValues.Azure.JS/Function.fs: -------------------------------------------------------------------------------- 1 | module WordValues.Azure.JS 2 | 3 | open System 4 | open System.Net 5 | open Fable.Core 6 | open Fable.Core.JsInterop 7 | open Thoth.Json 8 | 9 | open Interfaces 10 | open Response 11 | open Request 12 | 13 | open WordValues 14 | open Services 15 | 16 | let run (context : Context) (request : HttpRequest) = 17 | let wordParam = request.query.["word"] 18 | 19 | let response = createEmpty 20 | response.headers <- createEmpty // Is initially null 21 | 22 | 23 | let logger = ConsoleLogger("Function", context.log) 24 | 25 | match wordParam with 26 | | Some word -> 27 | let result = Calculate.wordValue logger word 28 | let content = result |> WordValue.Encoder |> Encode.toString 0 29 | 30 | response.statusCode <- (HttpStatusCode.OK |> float |> U2.op_ErasedCast |> Some) 31 | response.headers.["Content-Type"] <- ("application/json" :> obj |> Some) 32 | response.body <- (content :> obj |> Some) 33 | | None -> 34 | response.statusCode <- (HttpStatusCode.BadRequest |> float |> U2.op_ErasedCast |> Some) 35 | response.headers.["Content-Type"] <- ("text/plain;charset=utf-8" :> obj |> Some) 36 | response.body <- ("Required query parameter 'word' was missing" :> obj |> Some) 37 | 38 | context.res <- Some (response :> ContextRes) 39 | context.``done`` () 40 | 41 | exportDefault (Action<_, _> run) 42 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/Interfaces.fs: -------------------------------------------------------------------------------- 1 | // ts2fable 0.8.0-build.615 2 | module rec Interfaces 3 | 4 | #nowarn "3390" // disable warnings for invalid XML comments 5 | 6 | open System 7 | open Fable.Core 8 | open Fable.Core.JS 9 | 10 | type Error = System.Exception 11 | 12 | 13 | /// Context bindings object. Provided to your function binding data, as defined in function.json. 14 | type [] ContextBindings = 15 | [] abstract Item: name: string -> obj option with get, set 16 | 17 | /// Context binding data. Provided to your function trigger metadata and function invocation data. 18 | type [] ContextBindingData = 19 | abstract invocationId: string option with get, set 20 | [] abstract Item: name: string -> obj option with get, set 21 | 22 | /// The context object can be used for writing logs, reading data from bindings, setting outputs and using 23 | /// the context.done callback when your exported function is synchronous. A context object is passed 24 | /// to your function from the Azure Functions runtime on function invocation. 25 | type [] Context = 26 | /// A unique GUID per function invocation. 27 | abstract invocationId: string with get, set 28 | /// Function execution metadata. 29 | abstract executionContext: ExecutionContext with get, set 30 | /// Input and trigger binding data, as defined in function.json. Properties on this object are dynamically 31 | /// generated and named based off of the "name" property in function.json. 32 | abstract bindings: ContextBindings with get, set 33 | /// Trigger metadata and function invocation data. 34 | abstract bindingData: ContextBindingData with get, set 35 | /// TraceContext information to enable distributed tracing scenarios. 36 | abstract traceContext: TraceContext with get, set 37 | /// Bindings your function uses, as defined in function.json. 38 | abstract bindingDefinitions: ResizeArray with get, set 39 | /// Allows you to write streaming function logs. Calling directly allows you to write streaming function logs 40 | /// at the default trace level. 41 | abstract log: Logger with get, set 42 | /// 43 | /// A callback function that signals to the runtime that your code has completed. If your function is synchronous, 44 | /// you must call context.done at the end of execution. If your function is asynchronous, you should not use this 45 | /// callback. 46 | /// 47 | /// A user-defined error to pass back to the runtime. If present, your function execution will fail. 48 | /// 49 | /// An object containing output binding data. result will be passed to JSON.stringify unless it is 50 | /// a string, Buffer, ArrayBufferView, or number. 51 | /// 52 | abstract ``done``: ?err: U2 * ?result: obj -> unit 53 | /// HTTP request object. Provided to your function when using HTTP Bindings. 54 | abstract req: HttpRequest option with get, set 55 | /// HTTP response object. Provided to your function when using HTTP Bindings. 56 | abstract res: ContextRes option with get, set 57 | 58 | /// HTTP request headers. 59 | type [] HttpRequestHeaders = 60 | [] abstract Item: name: string -> string option with get, set 61 | 62 | /// Query string parameter keys and values from the URL. 63 | type [] HttpRequestQuery = 64 | [] abstract Item: name: string -> string option with get, set 65 | 66 | /// Route parameter keys and values. 67 | type [] HttpRequestParams = 68 | [] abstract Item: name: string -> string option with get, set 69 | 70 | /// HTTP request object. Provided to your function when using HTTP Bindings. 71 | type [] HttpRequest = 72 | /// HTTP request method used to invoke this function. 73 | abstract method: HttpMethod option with get, set 74 | /// Request URL. 75 | abstract url: string with get, set 76 | /// HTTP request headers. 77 | abstract headers: HttpRequestHeaders with get, set 78 | /// Query string parameter keys and values from the URL. 79 | abstract query: HttpRequestQuery with get, set 80 | /// Route parameter keys and values. 81 | abstract ``params``: HttpRequestParams with get, set 82 | /// The HTTP request body. 83 | abstract body: obj option with get, set 84 | /// The HTTP request body as a UTF-8 string. 85 | abstract rawBody: obj option with get, set 86 | 87 | /// Possible values for an HTTP request method. 88 | type [] [] HttpMethod = 89 | | [] GET 90 | | [] POST 91 | | [] DELETE 92 | | [] HEAD 93 | | [] PATCH 94 | | [] PUT 95 | | [] OPTIONS 96 | | [] TRACE 97 | | [] CONNECT 98 | 99 | /// Http response cookie object to "Set-Cookie" 100 | type [] Cookie = 101 | /// Cookie name 102 | abstract name: string with get, set 103 | /// Cookie value 104 | abstract value: string with get, set 105 | /// Specifies allowed hosts to receive the cookie 106 | abstract domain: string option with get, set 107 | /// Specifies URL path that must exist in the requested URL 108 | abstract path: string option with get, set 109 | /// NOTE: It is generally recommended that you use maxAge over expires. 110 | /// Sets the cookie to expire at a specific date instead of when the client closes. 111 | /// This can be a Javascript Date or Unix time in milliseconds. 112 | abstract expires: U2 option with get, set 113 | /// Sets the cookie to only be sent with an encrypted request 114 | abstract secure: bool option with get, set 115 | /// Sets the cookie to be inaccessible to JavaScript's Document.cookie API 116 | abstract httpOnly: bool option with get, set 117 | /// Can restrict the cookie to not be sent with cross-site requests 118 | abstract sameSite: CookieSameSite option with get, set 119 | /// Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. 120 | abstract maxAge: float option with get, set 121 | 122 | type [] ExecutionContext = 123 | /// A unique GUID per function invocation. 124 | abstract invocationId: string with get, set 125 | /// The name of the function that is being invoked. The name of your function is always the same as the 126 | /// name of the corresponding function.json's parent directory. 127 | abstract functionName: string with get, set 128 | /// The directory your function is in (this is the parent directory of this function's function.json). 129 | abstract functionDirectory: string with get, set 130 | /// The retry context of the current funciton execution. The retry context of the current function execution. Equals null if retry policy is not defined or it's the first function execution. 131 | abstract retryContext: RetryContext option with get, set 132 | 133 | type [] RetryContext = 134 | /// Current retry count of the function executions. 135 | abstract retryCount: float with get, set 136 | /// Max retry count is the maximum number of times an execution is retried before eventual failure. A value of -1 means to retry indefinitely. 137 | abstract maxRetryCount: float with get, set 138 | /// Exception that caused the retry 139 | abstract ``exception``: Exception option with get, set 140 | 141 | type [] Exception = 142 | /// Exception source 143 | abstract source: string option with get, set 144 | /// Exception stackTrace 145 | abstract stackTrace: string option with get, set 146 | /// Exception message 147 | abstract message: string option with get, set 148 | 149 | /// TraceContext information to enable distributed tracing scenarios. 150 | type [] TraceContext = 151 | /// Describes the position of the incoming request in its trace graph in a portable, fixed-length format. 152 | abstract traceparent: string option with get, set 153 | /// Extends traceparent with vendor-specific data. 154 | abstract tracestate: string option with get, set 155 | /// Holds additional properties being sent as part of request telemetry. 156 | abstract attributes: TraceContextAttributes option with get, set 157 | 158 | type [] BindingDefinition = 159 | /// The name of your binding, as defined in function.json. 160 | abstract name: string with get, set 161 | /// The type of your binding, as defined in function.json. 162 | abstract ``type``: string with get, set 163 | /// The direction of your binding, as defined in function.json. 164 | abstract direction: BindingDefinitionDirection with get, set 165 | 166 | /// Allows you to write streaming function logs. 167 | type [] Logger = 168 | /// Writes streaming function logs at the default trace level. 169 | [] abstract Invoke: [] args: obj option[] -> unit 170 | /// Writes to error level logging or lower. 171 | abstract error: [] args: obj option[] -> unit 172 | /// Writes to warning level logging or lower. 173 | abstract warn: [] args: obj option[] -> unit 174 | /// Writes to info level logging or lower. 175 | abstract info: [] args: obj option[] -> unit 176 | /// Writes to verbose level logging. 177 | abstract verbose: [] args: obj option[] -> unit 178 | 179 | type [] ContextRes = 180 | [] abstract Item: key: string -> obj option with get, set 181 | 182 | type [] [] CookieSameSite = 183 | | [] Strict 184 | | [] Lax 185 | | [] None 186 | 187 | type [] TraceContextAttributes = 188 | [] abstract Item: k: string -> string with get, set 189 | 190 | type [] [] BindingDefinitionDirection = 191 | | In 192 | | Out 193 | | Inout 194 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/Request.fs: -------------------------------------------------------------------------------- 1 | // ts2fable 0.8.0-build.615 2 | module rec Request 3 | open System 4 | open Fable.Core 5 | open Fable.Core.JS 6 | 7 | type HttpRequest = Interfaces.HttpRequest 8 | type HttpMethod = Interfaces.HttpMethod 9 | 10 | type [] IExports = 11 | abstract RequestProperties: RequestPropertiesStatic 12 | abstract Request: RequestStatic 13 | 14 | type [] RequestProperties = 15 | inherit HttpRequest 16 | abstract method: HttpMethod option with get, set 17 | abstract url: string with get, set 18 | abstract originalUrl: string with get, set 19 | abstract headers: RequestPropertiesHeaders with get, set 20 | abstract query: RequestPropertiesHeaders with get, set 21 | abstract ``params``: RequestPropertiesHeaders with get, set 22 | abstract body: obj option with get, set 23 | abstract rawBody: obj option with get, set 24 | [] abstract Item: key: string -> obj option with get, set 25 | 26 | type [] RequestPropertiesStatic = 27 | [] abstract Create: unit -> RequestProperties 28 | 29 | type [] Request = 30 | inherit RequestProperties 31 | abstract get: field: string -> string option 32 | 33 | type [] RequestStatic = 34 | [] abstract Create: httpInput: RequestProperties -> Request 35 | 36 | type [] RequestPropertiesHeaders = 37 | [] abstract Item: key: string -> string with get, set 38 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/Response.fs: -------------------------------------------------------------------------------- 1 | // ts2fable 0.8.0-build.615 2 | module rec Response 3 | open System 4 | open Fable.Core 5 | open Fable.Core.JS 6 | open Interfaces 7 | 8 | type Function = System.Action 9 | 10 | type Cookie = Interfaces.Cookie 11 | 12 | type [] IExports = 13 | abstract Response: ResponseStatic 14 | 15 | type [] IResponse = 16 | abstract statusCode: U2 option with get, set 17 | abstract headers: IResponseHeaders with get, set 18 | abstract cookies: ResizeArray with get, set 19 | abstract body: obj option with get, set 20 | abstract get: field: string -> obj option 21 | abstract set: field: string * ``val``: obj option -> IResponse 22 | abstract header: field: string * ``val``: obj option -> IResponse 23 | abstract status: statusCode: U2 -> IResponse 24 | 25 | type [] Response = 26 | inherit IResponse 27 | inherit ContextRes 28 | abstract statusCode: U2 option with get, set 29 | abstract headers: IResponseHeaders with get, set 30 | abstract cookies: ResizeArray with get, set 31 | abstract body: obj option with get, set 32 | abstract enableContentNegotiation: bool option with get, set 33 | [] abstract Item: key: string -> obj option with get, set 34 | abstract ``end``: ?body: obj -> unit 35 | abstract setHeader: field: string * ``val``: obj option -> IResponse 36 | abstract getHeader: field: string -> IResponse 37 | abstract removeHeader: field: string -> unit 38 | abstract status: statusCode: U2 -> IResponse 39 | abstract sendStatus: statusCode: U2 -> unit 40 | abstract ``type``: ``type``: obj -> unit 41 | abstract json: body: obj -> unit 42 | abstract send: obj with get, set 43 | abstract header: obj with get, set 44 | abstract set: obj with get, set 45 | abstract get: obj with get, set 46 | 47 | type [] ResponseStatic = 48 | [] abstract Create: ``done``: Function -> Response 49 | 50 | type [] IResponseHeaders = 51 | [] abstract Item: key: string -> obj option with get, set 52 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/Services.fs: -------------------------------------------------------------------------------- 1 | module Services 2 | 3 | #if FABLE_COMPILER 4 | open Thoth.Json 5 | #else 6 | open Thoth.Json.Net 7 | open Interfaces 8 | #endif 9 | 10 | open Services 11 | open Services.JS 12 | 13 | type ConsoleLogger (category : string, logger : Interfaces.Logger) = 14 | interface Services.ILogger with 15 | member _.Log level event = 16 | let logEntry = { EventId = event.EventId; LogLevel = level; Category = category; Message = event.Message; State = event.Params } 17 | 18 | let json = logEntry |> LogEntry.toString 0 :> obj |> Some 19 | match level with 20 | | Critical 21 | | Error -> logger.error json 22 | | Warn -> logger.warn json 23 | | Info -> logger.info json 24 | | Trace 25 | | Debug -> logger.verbose json 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/WordValue/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /WordValues.Azure.JS/WordValues.Azure.JS.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensionBundle": { 4 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 5 | "version": "[2.*, 3.0.0)" 6 | } 7 | } -------------------------------------------------------------------------------- /WordValues.Azure.JS/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "node", 5 | "AzureWebJobsStorage": "UseDevelopmentStorage=true" 6 | } 7 | } -------------------------------------------------------------------------------- /WordValues.Azure.JS/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "Function.fs.js", 3 | "parcel-namer-rewrite": { 4 | "chain": "@parcel/namer-default", 5 | "rules": { 6 | "Function.fs.js": "index.js" 7 | } 8 | }, 9 | "scripts": { 10 | "build": "parcel build" 11 | }, 12 | "devDependencies": { 13 | "parcel": "^2.5.0", 14 | "parcel-namer-rewrite": "^2.0.0-rc.2" 15 | }, 16 | "targets": { 17 | "default": { 18 | "distDir": "./WordValue", 19 | "context": "node", 20 | "outputFormat": "commonjs", 21 | "isLibrary": true, 22 | "optimize": true, 23 | "includeNodeModules": false 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /WordValues.Azure.JS/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Fable.Core 3 | Thoth.Json -------------------------------------------------------------------------------- /WordValues.Azure.Tests/Tests.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues.Azure.Tests 2 | 3 | open System 4 | 5 | open Xunit 6 | 7 | open Testing.Apis 8 | 9 | type WordValuesAzureFuncInstance() = 10 | inherit AzureFuncInstance(typeof, <@ WordValues.Azure.Program.main @>, 7071) 11 | 12 | // TODO - category for slow tests that require func.exe 13 | type TestAzureFun (func : WordValuesAzureFuncInstance) = 14 | inherit TestWordValueEndpoints(fun () -> Uri(func.GetConnection().BaseUri, "/api/WordValue")) 15 | interface IClassFixture 16 | -------------------------------------------------------------------------------- /WordValues.Azure.Tests/WordValues.Azure.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0 5 | false 6 | Library 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /WordValues.Azure.Tests/paket.references: -------------------------------------------------------------------------------- 1 | Microsoft.NET.Test.Sdk 2 | SchlenkR.FsHttp 3 | xunit 4 | xunit.runner.visualstudio 5 | coverlet.collector 6 | FSharp.Core -------------------------------------------------------------------------------- /WordValues.Azure/Function.fs: -------------------------------------------------------------------------------- 1 | module WordValues.Azure.Function 2 | 3 | open System.Net 4 | open System.Web 5 | open System.Collections.Specialized 6 | open Thoth.Json.Net 7 | 8 | open Microsoft.Azure.Functions.Worker 9 | open Microsoft.Azure.Functions.Worker.Http 10 | 11 | open Services.Clr 12 | open WordValues 13 | 14 | module NameValueCollection = 15 | let tryGet (key : string) (nvc : NameValueCollection) = 16 | nvc.[key] |> Option.ofObj 17 | 18 | [] 19 | let run ([] request:HttpRequestData, executionContext:FunctionContext) : HttpResponseData = 20 | let logger = MicrosoftLogger(executionContext.GetLogger("Function")) 21 | 22 | let wordParam = 23 | HttpUtility.ParseQueryString(request.Url.Query) 24 | |> NameValueCollection.tryGet "word" 25 | match wordParam with 26 | | Some word -> 27 | let result = Calculate.wordValue logger word 28 | let content = result |> WordValue.Encoder |> Encode.toString 0 29 | 30 | let response = request.CreateResponse(HttpStatusCode.OK) 31 | response.Headers.Add("Content-Type", "application/json") 32 | response.WriteString(content) 33 | response 34 | | None -> 35 | let response = request.CreateResponse(HttpStatusCode.BadRequest) 36 | response.Headers.Add("Content-Type", "text/plain;charset=utf-8") 37 | response.WriteString("Required query parameter 'word' was missing") 38 | response 39 | 40 | -------------------------------------------------------------------------------- /WordValues.Azure/Program.fs: -------------------------------------------------------------------------------- 1 | module WordValues.Azure.Program 2 | 3 | open Microsoft.Extensions.Hosting 4 | 5 | let [] main _ = 6 | HostBuilder() 7 | .ConfigureFunctionsWorkerDefaults() 8 | .Build() 9 | .Run() 10 | 0 11 | -------------------------------------------------------------------------------- /WordValues.Azure/WordValues.Azure.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0 5 | preview 6 | v3 7 | Exe 8 | <_FunctionsSkipCleanOutput>true 9 | 10 | 11 | 12 | 13 | 14 | 15 | PreserveNewest 16 | 17 | 18 | PreserveNewest 19 | Never 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /WordValues.Azure/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensions": { 4 | "http": { 5 | "routePrefix": "api" 6 | } 7 | }, 8 | "logging": { 9 | "applicationInsights": { 10 | "samplingSettings": { 11 | "isEnabled": true, 12 | "excludedTypes": "Request" 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /WordValues.Azure/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" 6 | } 7 | } -------------------------------------------------------------------------------- /WordValues.Azure/paket.references: -------------------------------------------------------------------------------- 1 | Microsoft.Azure.Functions.Worker.Sdk 2 | Microsoft.Azure.Functions.Worker 3 | Microsoft.Azure.Functions.Worker.Extensions.Http 4 | FSharp.Core 5 | System.Text.Json -------------------------------------------------------------------------------- /WordValues.Tests/TestCalculate.fs: -------------------------------------------------------------------------------- 1 | module WordValues.Tests.TestCalculate 2 | 3 | open System 4 | 5 | open Xunit 6 | open Hedgehog 7 | open Swensen.Unquote 8 | 9 | open Testing.Services 10 | 11 | open WordValues 12 | 13 | let reverse (str: string) = 14 | str |> Seq.rev |> Array.ofSeq |> String 15 | 16 | // Partially bind the Testing Logger implementation 17 | module Calculate = 18 | let wordValue = Calculate.wordValue (TestLogger.Default) 19 | let wordsFromValue = Calculate.wordsFromValue (TestLogger.Default) 20 | 21 | module Gen = 22 | let nonNullString = 23 | Gen.string (Range.linear 0 100) (Gen.char Char.MinValue Char.MaxValue) 24 | 25 | let wordList = 26 | Gen.string (Range.linear 1 20) Gen.alpha 27 | |> Gen.map (fun w -> w, (Calculate.wordValue w).Value) 28 | |> Gen.list (Range.linear 0 100) 29 | 30 | module Property = 31 | let regressionTest size seed prop = 32 | Property.recheck size seed prop 33 | prop 34 | 35 | [] 36 | let ``Value of 'HELLO' is correct`` () = 37 | test <@ (Calculate.wordValue "HELLO").Value = 8 + 5 + 12 + 12 + 15 @> 38 | 39 | [] 40 | let ``Value of 'hello' is correct`` () = 41 | test <@ (Calculate.wordValue "hello").Value = 8 + 5 + 12 + 12 + 15 @> 42 | 43 | [] 44 | let ``No warnings produced for 'hello'`` () = 45 | test <@ (Calculate.wordValue "hello").Warning = None @> 46 | 47 | [] 48 | let ``Value of 'HELLO 123' contains warnings`` () = 49 | test <@ (Calculate.wordValue "HELLO 123").Warning = Some "Ignored ' ','1','2','3'" @> 50 | 51 | [] 52 | let ``Value of text is same as value of upper case`` () = 53 | property { 54 | let! str = Gen.nonNullString 55 | test <@ (Calculate.wordValue str) = Calculate.wordValue (str.ToUpper()) @> 56 | } |> Property.check 57 | 58 | [] 59 | let ``Value of text is same as value of lower case`` () = 60 | property { 61 | let! str = Gen.nonNullString 62 | test <@ (Calculate.wordValue str).Value = (Calculate.wordValue (str.ToLower())).Value @> 63 | } 64 | |> Property.regressionTest 91 { Value = 9535703340393401501UL; Gamma = 8182104926013755423UL } 65 | |> Property.check 66 | 67 | [] 68 | let ``Value of text is same as value of reversed text`` () = 69 | property { 70 | let! str = Gen.nonNullString 71 | test <@ Calculate.wordValue str = Calculate.wordValue (reverse str) @> 72 | } |> Property.check 73 | 74 | [] 75 | let ``Value of text is below maximum value`` () = 76 | property { 77 | let! str = Gen.nonNullString 78 | test <@ (Calculate.wordValue str).Value <= 26 * str.Length @> 79 | } 80 | |> Property.regressionTest 1 { Value = 1298872065959223496UL; Gamma = 772578873708680621UL } 81 | |> Property.check 82 | 83 | [] 84 | let ``Warning contains non-letters`` () = 85 | let isNonLetter c = not (( c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) 86 | 87 | property { 88 | let! str = Gen.nonNullString 89 | let nonLetters = str |> Seq.filter isNonLetter |> Seq.map Char.ToUpperInvariant 90 | let wordValue = Calculate.wordValue str 91 | 92 | let notWarnedAbout = 93 | nonLetters 94 | |> Seq.filter (fun c -> not (wordValue.Warning.Value.Contains(sprintf "'%c'" c))) 95 | 96 | test <@ Seq.isEmpty notWarnedAbout @> 97 | } 98 | |> Property.regressionTest 31 { Value = 10002960666613865206UL; Gamma = 14428377911873522553UL } 99 | |> Property.check 100 | 101 | let dictionary = [ ("1", 1); ("2", 2); ("5", 5); ("12", 12) ] 102 | 103 | [] 104 | let ``wordsFromValue has no results for impossible totals`` () = 105 | test <@ Calculate.wordsFromValue dictionary 0 = [] @> 106 | test <@ Calculate.wordsFromValue dictionary -7 = [] @> 107 | 108 | [] 109 | let ``wordsFromValue builds correct sequences`` () = 110 | let expectedSevens = 111 | [ 112 | [ "1"; "1"; "1"; "1"; "1"; "1"; "1" ] 113 | [ "2"; "1"; "1"; "1"; "1"; "1" ] 114 | [ "2"; "2"; "1"; "1"; "1" ] 115 | [ "2"; "2"; "2"; "1" ] 116 | [ "5"; "1"; "1" ] 117 | [ "5"; "2" ] 118 | ] 119 | 120 | let actual = 121 | Calculate.wordsFromValue dictionary 7 122 | |> List.map (List.sortDescending) 123 | |> List.sort 124 | 125 | test <@ actual = expectedSevens @> 126 | 127 | [] 128 | let ``wordsFromValue matches wordValue`` () = 129 | property { 130 | let! target = Gen.int (Range.linear 0 100) 131 | let! wordList = Gen.wordList 132 | let result = 133 | Calculate.wordsFromValue wordList target 134 | |> List.map (fun words -> 135 | let str = String.concat " " words 136 | (str, Calculate.wordValue str)) 137 | test <@ result |> List.forall (fun (s,v) -> v.Value = target) @> 138 | } |> Property.check 139 | -------------------------------------------------------------------------------- /WordValues.Tests/WordValues.Tests.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net5.0 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /WordValues.Tests/paket.references: -------------------------------------------------------------------------------- 1 | Microsoft.NET.Test.Sdk 2 | xunit 3 | xunit.runner.visualstudio 4 | coverlet.collector 5 | FSharp.Core 6 | Unquote 7 | Hedgehog -------------------------------------------------------------------------------- /WordValues/Calculate.fs: -------------------------------------------------------------------------------- 1 | namespace WordValues 2 | 3 | open System 4 | 5 | #if FABLE_COMPILER 6 | open Thoth.Json 7 | #else 8 | open Thoth.Json.Net 9 | #endif 10 | 11 | open Services 12 | 13 | type WordValue = { Value : int; Warning : string option } 14 | with 15 | static member Encoder (v : WordValue) = 16 | Encode.object [ 17 | ("Value", Encode.int v.Value) 18 | match v.Warning with 19 | | Some warn -> ("Warning", Encode.string warn) 20 | | None -> () 21 | ] 22 | 23 | module Calculate = 24 | let isAsciiUCLetter c = 25 | c >= 'A' && c <= 'Z' 26 | 27 | let wordValue (logger : ILogger) (text : string) : WordValue = 28 | Log.info logger (LogEvent.Create("wordValue of {text}", text)) 29 | 30 | let (letters, nonLetters) = 31 | text.ToUpper() 32 | |> List.ofSeq 33 | |> List.partition isAsciiUCLetter 34 | 35 | let value = 36 | letters 37 | |> List.sumBy (fun letter -> (Char.ToUpperInvariant letter |> int) - (int 'A') + 1) 38 | 39 | let warning = 40 | if List.isEmpty nonLetters then 41 | None 42 | else 43 | nonLetters 44 | |> List.distinct 45 | |> List.sort 46 | |> List.map (sprintf "'%c'") 47 | |> String.concat "," 48 | |> sprintf "Ignored %s" 49 | |> Some 50 | 51 | Log.info logger (LogEvent.Create("wordValue returning value {value} with warning {warning}", value, warning)) 52 | 53 | { 54 | Value = value 55 | Warning = warning 56 | } 57 | 58 | let wordsFromValue (logger : ILogger) (wordValues : (string*int) list) (value : int) : string list list = 59 | let rec fit acc ws t = 60 | match ws, t with 61 | | _ , 0 -> [acc] // That's a solution 62 | | [] , _ -> [] // No more words to try 63 | | (w,v)::rest, _ -> 64 | (if (t < v) then [] else fit (w::acc) ws (t - v)) // Use w and fit the remainder 65 | @ (fit acc rest t) // Also try without using w 66 | 67 | Log.info logger (LogEvent.Create("wordsFromValue seeking {value}", value)) 68 | 69 | let result = 70 | fit [] wordValues value 71 | |> List.filter (not << List.isEmpty) 72 | 73 | Log.info logger (LogEvent.Create("wordsFromValue got {count} results", result.Length)) 74 | 75 | result -------------------------------------------------------------------------------- /WordValues/WordValues.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | true 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /WordValues/paket.references: -------------------------------------------------------------------------------- 1 | FSharp.Core 2 | Thoth.Json 3 | Thoth.Json.Net -------------------------------------------------------------------------------- /build.fsx: -------------------------------------------------------------------------------- 1 | #if FAKE 2 | #r """paket: 3 | source https://api.nuget.org/v3/index.json 4 | nuget FSharp.Core 4.7.2 5 | nuget Fake.Core.Target 6 | nuget Fake.IO.Zip 7 | nuget Fake.DotNet.Cli 8 | nuget Fake.JavaScript.Yarn 9 | //""" 10 | #endif 11 | 12 | #load "./.fake/build.fsx/intellisense.fsx" 13 | 14 | open Fake.Core 15 | open Fake.Core.TargetOperators 16 | open Fake.IO.FileSystemOperators 17 | open Fake.IO.Globbing.Operators 18 | open Fake.DotNet 19 | open Fake.JavaScript 20 | 21 | module Target = 22 | let create name description body = 23 | Target.description description 24 | Target.create name body 25 | name 26 | 27 | let solutionFolder = __SOURCE_DIRECTORY__ 28 | let solutionFile = "EverythingAsCodeFSharp.sln" 29 | 30 | let dotNetOpt (opt : DotNet.Options) = 31 | { opt with WorkingDirectory = solutionFolder } 32 | 33 | let publishOpt (opt : DotNet.PublishOptions) = 34 | opt.WithCommon dotNetOpt 35 | 36 | let publishAwsLambdaOpt (opt : DotNet.PublishOptions) = 37 | { (opt |> publishOpt) with Runtime = Some "linux-x64" } 38 | 39 | let buildOpt (opt : DotNet.BuildOptions) = 40 | opt.WithCommon dotNetOpt 41 | 42 | let testOpt (opt : DotNet.TestOptions) = 43 | opt.WithCommon dotNetOpt 44 | 45 | type ProcessHelpers = 46 | static member checkResult (p : ProcessResult) = 47 | if p.ExitCode <> 0 48 | then failwithf "Expected exit code 0, but was %d" p.ExitCode 49 | 50 | static member checkResult (p : ProcessResult<_>) = 51 | if p.ExitCode <> 0 52 | then failwithf "Expected exit code 0, but was %d" p.ExitCode 53 | 54 | let runExe exe workingFolder arguments = 55 | Command.RawCommand (exe, Arguments.ofList arguments) 56 | |> CreateProcess.fromCommand 57 | |> CreateProcess.withWorkingDirectory workingFolder 58 | |> CreateProcess.ensureExitCode 59 | |> Proc.run 60 | |> ProcessHelpers.checkResult 61 | 62 | let build = 63 | Target.create "Build" "Build the solution" (fun _ -> 64 | DotNet.build buildOpt solutionFile 65 | ) 66 | 67 | let unitTests = 68 | Target.create "UnitTests" "Run the unit tests" (fun _ -> 69 | DotNet.test testOpt "WordValues.Tests" 70 | ) 71 | 72 | let publishAzureFunc = 73 | Target.create "PublishAzureFunc" "Publish the Azure Function" (fun _ -> 74 | DotNet.publish publishOpt "WordValues.Azure" 75 | ) 76 | 77 | let publishAzureJSFunc = 78 | Target.create "PublishAzureJSFunc" "Publish the Azure Function as Javascript" (fun _ -> 79 | let projectFolder = solutionFolder "WordValues.Azure.JS" 80 | let yarnParams (opt : Yarn.YarnParams) = { opt with WorkingDirectory = projectFolder } 81 | DotNet.exec dotNetOpt "fable" "WordValues.Azure.JS" |> ProcessHelpers.checkResult 82 | Yarn.install yarnParams 83 | Yarn.exec "build" yarnParams 84 | let publishZip = System.IO.Path.Combine(projectFolder, "publish.zip") 85 | let zipFiles = 86 | !! (projectFolder "WordValue/**/*.*") 87 | ++ (projectFolder "host.json") 88 | Fake.IO.Zip.createZip projectFolder publishZip "" Fake.IO.Zip.DefaultZipLevel false zipFiles 89 | ) 90 | 91 | let publishAwsLambda = 92 | Target.create "PublishAwsLambda" "Publish the Aws Lambda" (fun _ -> 93 | DotNet.publish publishAwsLambdaOpt "WordValues.Aws" 94 | let publishFolder = System.IO.Path.Combine(solutionFolder, "WordValues.Aws", "bin", "Release", "net5.0", "linux-x64", "publish") 95 | let publishZip = System.IO.Path.Combine(solutionFolder, "WordValues.Aws", "bin", "Release", "net5.0", "linux-x64", "publish.zip") 96 | Fake.IO.Zip.createZip publishFolder publishZip "" Fake.IO.Zip.DefaultZipLevel false ( !! (publishFolder"**/*.*")) 97 | ) 98 | 99 | let publishAwsJSLambda = 100 | Target.create "PublishAwsJSLambda" "Publish the Aws Lambda as Javascript" (fun _ -> 101 | let projectFolder = solutionFolder "WordValues.Aws.JS" 102 | let yarnParams (opt : Yarn.YarnParams) = { opt with WorkingDirectory = projectFolder } 103 | DotNet.exec dotNetOpt "fable" "WordValues.Aws.JS" |> ProcessHelpers.checkResult 104 | Yarn.install yarnParams 105 | Yarn.exec "build" yarnParams 106 | let publishZip = System.IO.Path.Combine(projectFolder, "publish.zip") 107 | let zipFiles = 108 | !! (projectFolder "WordValue/**/*.*") 109 | Fake.IO.Zip.createZip (projectFolder"WordValue") publishZip "" Fake.IO.Zip.DefaultZipLevel false zipFiles 110 | ) 111 | 112 | let localTestAzureFunc = 113 | Target.create "LocalTestAzureFunc" "Test the Azure Function locally" (fun _ -> 114 | DotNet.test testOpt "WordValues.Azure.Tests" 115 | ) 116 | 117 | let localTestAzureJSFunc = 118 | Target.create "LocalTestAzureJSFunc" "Test the Azure JavaScript Function locally" (fun _ -> 119 | DotNet.test testOpt "WordValues.Azure.JS.Tests" 120 | ) 121 | 122 | let pulumiDeployAzure = 123 | Target.create "PulumiDeployAzure" "Deploy to Azure" (fun _ -> 124 | runExe 125 | "pulumi" 126 | (solutionFolder"Deployment.Azure") 127 | [ "up"; "-y"; "-s"; "azure-dev" ] 128 | ) 129 | 130 | let pulumiDeployAws = 131 | Target.create "PulumiDeployAws" "Deploy to Aws" (fun _ -> 132 | runExe 133 | "pulumi" 134 | (solutionFolder"Deployment.Aws") 135 | [ "up"; "-y"; "-s"; "aws-dev" ] 136 | ) 137 | 138 | let deployedTest = 139 | Target.create "DeployedTest" "Test the deployment" (fun _ -> 140 | DotNet.test testOpt "Deployment.Tests" 141 | ) 142 | 143 | let publishAll = 144 | Target.create "PublishAll" "Publish all the Functions and Lambdas" ignore 145 | 146 | build ==> unitTests 147 | build ==> publishAzureFunc 148 | build ==> publishAzureJSFunc 149 | build ==> publishAwsLambda 150 | build ==> publishAwsJSLambda 151 | 152 | publishAzureFunc ==> localTestAzureFunc 153 | publishAzureJSFunc ==> localTestAzureJSFunc 154 | 155 | publishAzureFunc ==> pulumiDeployAzure 156 | publishAzureJSFunc ==> pulumiDeployAzure 157 | 158 | publishAwsLambda ==> pulumiDeployAws 159 | publishAwsJSLambda ==> pulumiDeployAws 160 | 161 | publishAzureFunc ==> publishAll 162 | publishAzureJSFunc ==> publishAll 163 | publishAwsLambda ==> publishAll 164 | publishAwsJSLambda ==> publishAll 165 | 166 | 167 | // Default target 168 | Target.runOrDefault build -------------------------------------------------------------------------------- /build.fsx.lock: -------------------------------------------------------------------------------- 1 | STORAGE: NONE 2 | RESTRICTION: == netstandard2.0 3 | NUGET 4 | remote: https://api.nuget.org/v3/index.json 5 | BlackFox.VsWhere (1.1) 6 | FSharp.Core (>= 4.2.3) 7 | Microsoft.Win32.Registry (>= 4.7) 8 | Fake.Core.CommandLineParsing (5.20.4) 9 | FParsec (>= 1.1.1) 10 | FSharp.Core (>= 4.7.2) 11 | Fake.Core.Context (5.20.4) 12 | FSharp.Core (>= 4.7.2) 13 | Fake.Core.Environment (5.20.4) 14 | FSharp.Core (>= 4.7.2) 15 | Fake.Core.FakeVar (5.20.4) 16 | Fake.Core.Context (>= 5.20.4) 17 | FSharp.Core (>= 4.7.2) 18 | Fake.Core.Process (5.20.4) 19 | Fake.Core.Environment (>= 5.20.4) 20 | Fake.Core.FakeVar (>= 5.20.4) 21 | Fake.Core.String (>= 5.20.4) 22 | Fake.Core.Trace (>= 5.20.4) 23 | Fake.IO.FileSystem (>= 5.20.4) 24 | FSharp.Core (>= 4.7.2) 25 | System.Collections.Immutable (>= 1.7.1) 26 | Fake.Core.SemVer (5.20.4) 27 | FSharp.Core (>= 4.7.2) 28 | Fake.Core.String (5.20.4) 29 | FSharp.Core (>= 4.7.2) 30 | Fake.Core.Target (5.20.4) 31 | Fake.Core.CommandLineParsing (>= 5.20.4) 32 | Fake.Core.Context (>= 5.20.4) 33 | Fake.Core.Environment (>= 5.20.4) 34 | Fake.Core.FakeVar (>= 5.20.4) 35 | Fake.Core.Process (>= 5.20.4) 36 | Fake.Core.String (>= 5.20.4) 37 | Fake.Core.Trace (>= 5.20.4) 38 | FSharp.Control.Reactive (>= 4.4.2) 39 | FSharp.Core (>= 4.7.2) 40 | Fake.Core.Tasks (5.20.4) 41 | Fake.Core.Trace (>= 5.20.4) 42 | FSharp.Core (>= 4.7.2) 43 | Fake.Core.Trace (5.20.4) 44 | Fake.Core.Environment (>= 5.20.4) 45 | Fake.Core.FakeVar (>= 5.20.4) 46 | FSharp.Core (>= 4.7.2) 47 | Fake.Core.Xml (5.20.4) 48 | Fake.Core.String (>= 5.20.4) 49 | FSharp.Core (>= 4.7.2) 50 | Fake.DotNet.Cli (5.20.4) 51 | Fake.Core.Environment (>= 5.20.4) 52 | Fake.Core.Process (>= 5.20.4) 53 | Fake.Core.String (>= 5.20.4) 54 | Fake.Core.Trace (>= 5.20.4) 55 | Fake.DotNet.MSBuild (>= 5.20.4) 56 | Fake.DotNet.NuGet (>= 5.20.4) 57 | Fake.IO.FileSystem (>= 5.20.4) 58 | FSharp.Core (>= 4.7.2) 59 | Mono.Posix.NETStandard (>= 1.0) 60 | Newtonsoft.Json (>= 12.0.3) 61 | Fake.DotNet.MSBuild (5.20.4) 62 | BlackFox.VsWhere (>= 1.1) 63 | Fake.Core.Environment (>= 5.20.4) 64 | Fake.Core.Process (>= 5.20.4) 65 | Fake.Core.String (>= 5.20.4) 66 | Fake.Core.Trace (>= 5.20.4) 67 | Fake.IO.FileSystem (>= 5.20.4) 68 | FSharp.Core (>= 4.7.2) 69 | MSBuild.StructuredLogger (>= 2.1.176) 70 | Fake.DotNet.NuGet (5.20.4) 71 | Fake.Core.Environment (>= 5.20.4) 72 | Fake.Core.Process (>= 5.20.4) 73 | Fake.Core.SemVer (>= 5.20.4) 74 | Fake.Core.String (>= 5.20.4) 75 | Fake.Core.Tasks (>= 5.20.4) 76 | Fake.Core.Trace (>= 5.20.4) 77 | Fake.Core.Xml (>= 5.20.4) 78 | Fake.IO.FileSystem (>= 5.20.4) 79 | Fake.Net.Http (>= 5.20.4) 80 | FSharp.Core (>= 4.7.2) 81 | Newtonsoft.Json (>= 12.0.3) 82 | NuGet.Protocol (>= 5.6) 83 | Fake.IO.FileSystem (5.20.4) 84 | Fake.Core.String (>= 5.20.4) 85 | FSharp.Core (>= 4.7.2) 86 | Fake.IO.Zip (5.20.4) 87 | Fake.Core.String (>= 5.20.4) 88 | Fake.IO.FileSystem (>= 5.20.4) 89 | FSharp.Core (>= 4.7.2) 90 | Fake.JavaScript.Yarn (5.20.4) 91 | Fake.Core.Environment (>= 5.20.4) 92 | Fake.Core.Process (>= 5.20.4) 93 | FSharp.Core (>= 4.7.2) 94 | Fake.Net.Http (5.20.4) 95 | Fake.Core.Trace (>= 5.20.4) 96 | FSharp.Core (>= 4.7.2) 97 | FParsec (1.1.1) 98 | FSharp.Core (>= 4.3.4) 99 | FSharp.Control.Reactive (5.0.2) 100 | FSharp.Core (>= 4.7.2) 101 | System.Reactive (>= 5.0) 102 | FSharp.Core (4.7.2) 103 | Microsoft.Build (16.9) 104 | Microsoft.Build.Framework (16.9) 105 | System.Security.Permissions (>= 4.7) 106 | Microsoft.Build.Tasks.Core (16.9) 107 | Microsoft.Build.Framework (>= 16.9) 108 | Microsoft.Build.Utilities.Core (>= 16.9) 109 | Microsoft.Win32.Registry (>= 4.3) 110 | System.CodeDom (>= 4.4) 111 | System.Collections.Immutable (>= 5.0) 112 | System.Reflection.Metadata (>= 1.6) 113 | System.Reflection.TypeExtensions (>= 4.1) 114 | System.Resources.Extensions (>= 4.6) 115 | System.Runtime.InteropServices (>= 4.3) 116 | System.Security.Cryptography.Pkcs (>= 4.7) 117 | System.Security.Cryptography.Xml (>= 4.7) 118 | System.Security.Permissions (>= 4.7) 119 | System.Threading.Tasks.Dataflow (>= 4.9) 120 | Microsoft.Build.Utilities.Core (16.9) 121 | Microsoft.Build.Framework (>= 16.9) 122 | Microsoft.Win32.Registry (>= 4.3) 123 | System.Collections.Immutable (>= 5.0) 124 | System.Security.Permissions (>= 4.7) 125 | System.Text.Encoding.CodePages (>= 4.0.1) 126 | Microsoft.NETCore.Platforms (5.0.2) 127 | Microsoft.NETCore.Targets (5.0) 128 | Microsoft.Win32.Registry (5.0) 129 | System.Buffers (>= 4.5.1) 130 | System.Memory (>= 4.5.4) 131 | System.Security.AccessControl (>= 5.0) 132 | System.Security.Principal.Windows (>= 5.0) 133 | Mono.Posix.NETStandard (1.0) 134 | MSBuild.StructuredLogger (2.1.500) 135 | Microsoft.Build (>= 16.4) 136 | Microsoft.Build.Framework (>= 16.4) 137 | Microsoft.Build.Tasks.Core (>= 16.4) 138 | Microsoft.Build.Utilities.Core (>= 16.4) 139 | Newtonsoft.Json (13.0.1) 140 | NuGet.Common (5.9.1) 141 | NuGet.Frameworks (>= 5.9.1) 142 | NuGet.Configuration (5.9.1) 143 | NuGet.Common (>= 5.9.1) 144 | System.Security.Cryptography.ProtectedData (>= 4.4) 145 | NuGet.Frameworks (5.9.1) 146 | NuGet.Packaging (5.9.1) 147 | Newtonsoft.Json (>= 9.0.1) 148 | NuGet.Configuration (>= 5.9.1) 149 | NuGet.Versioning (>= 5.9.1) 150 | System.Security.Cryptography.Cng (>= 5.0) 151 | System.Security.Cryptography.Pkcs (>= 5.0) 152 | NuGet.Protocol (5.9.1) 153 | NuGet.Packaging (>= 5.9.1) 154 | NuGet.Versioning (5.9.1) 155 | System.Buffers (4.5.1) 156 | System.CodeDom (5.0) 157 | System.Collections.Immutable (5.0) 158 | System.Memory (>= 4.5.4) 159 | System.Formats.Asn1 (5.0) 160 | System.Buffers (>= 4.5.1) 161 | System.Memory (>= 4.5.4) 162 | System.IO (4.3) 163 | Microsoft.NETCore.Platforms (>= 1.1) 164 | Microsoft.NETCore.Targets (>= 1.1) 165 | System.Runtime (>= 4.3) 166 | System.Text.Encoding (>= 4.3) 167 | System.Threading.Tasks (>= 4.3) 168 | System.Memory (4.5.4) 169 | System.Buffers (>= 4.5.1) 170 | System.Numerics.Vectors (>= 4.4) 171 | System.Runtime.CompilerServices.Unsafe (>= 4.5.3) 172 | System.Numerics.Vectors (4.5) 173 | System.Reactive (5.0) 174 | System.Runtime.InteropServices.WindowsRuntime (>= 4.3) 175 | System.Threading.Tasks.Extensions (>= 4.5.4) 176 | System.Reflection (4.3) 177 | Microsoft.NETCore.Platforms (>= 1.1) 178 | Microsoft.NETCore.Targets (>= 1.1) 179 | System.IO (>= 4.3) 180 | System.Reflection.Primitives (>= 4.3) 181 | System.Runtime (>= 4.3) 182 | System.Reflection.Metadata (5.0) 183 | System.Collections.Immutable (>= 5.0) 184 | System.Reflection.Primitives (4.3) 185 | Microsoft.NETCore.Platforms (>= 1.1) 186 | Microsoft.NETCore.Targets (>= 1.1) 187 | System.Runtime (>= 4.3) 188 | System.Reflection.TypeExtensions (4.7) 189 | System.Resources.Extensions (5.0) 190 | System.Memory (>= 4.5.4) 191 | System.Runtime (4.3.1) 192 | Microsoft.NETCore.Platforms (>= 1.1.1) 193 | Microsoft.NETCore.Targets (>= 1.1.3) 194 | System.Runtime.CompilerServices.Unsafe (5.0) 195 | System.Runtime.Handles (4.3) 196 | Microsoft.NETCore.Platforms (>= 1.1) 197 | Microsoft.NETCore.Targets (>= 1.1) 198 | System.Runtime (>= 4.3) 199 | System.Runtime.InteropServices (4.3) 200 | Microsoft.NETCore.Platforms (>= 1.1) 201 | Microsoft.NETCore.Targets (>= 1.1) 202 | System.Reflection (>= 4.3) 203 | System.Reflection.Primitives (>= 4.3) 204 | System.Runtime (>= 4.3) 205 | System.Runtime.Handles (>= 4.3) 206 | System.Runtime.InteropServices.WindowsRuntime (4.3) 207 | System.Runtime (>= 4.3) 208 | System.Security.AccessControl (5.0) 209 | System.Security.Principal.Windows (>= 5.0) 210 | System.Security.Cryptography.Cng (5.0) 211 | System.Security.Cryptography.Pkcs (5.0.1) 212 | System.Buffers (>= 4.5.1) 213 | System.Formats.Asn1 (>= 5.0) 214 | System.Memory (>= 4.5.4) 215 | System.Security.Cryptography.Cng (>= 5.0) 216 | System.Security.Cryptography.ProtectedData (5.0) 217 | System.Memory (>= 4.5.4) 218 | System.Security.Cryptography.Xml (5.0) 219 | System.Memory (>= 4.5.4) 220 | System.Security.Cryptography.Pkcs (>= 5.0) 221 | System.Security.Permissions (>= 5.0) 222 | System.Security.Permissions (5.0) 223 | System.Security.AccessControl (>= 5.0) 224 | System.Security.Principal.Windows (5.0) 225 | System.Text.Encoding (4.3) 226 | Microsoft.NETCore.Platforms (>= 1.1) 227 | Microsoft.NETCore.Targets (>= 1.1) 228 | System.Runtime (>= 4.3) 229 | System.Text.Encoding.CodePages (5.0) 230 | System.Runtime.CompilerServices.Unsafe (>= 5.0) 231 | System.Threading.Tasks (4.3) 232 | Microsoft.NETCore.Platforms (>= 1.1) 233 | Microsoft.NETCore.Targets (>= 1.1) 234 | System.Runtime (>= 4.3) 235 | System.Threading.Tasks.Dataflow (5.0) 236 | System.Threading.Tasks.Extensions (4.5.4) 237 | System.Runtime.CompilerServices.Unsafe (>= 4.5.3) 238 | -------------------------------------------------------------------------------- /paket.dependencies: -------------------------------------------------------------------------------- 1 | source https://api.nuget.org/v3/index.json 2 | 3 | storage: none 4 | framework: auto-detect 5 | nuget Amazon.Lambda.APIGatewayEvents 6 | nuget Amazon.Lambda.Core 7 | nuget Amazon.Lambda.Logging.AspNetCore 8 | nuget Amazon.Lambda.RuntimeSupport 9 | nuget Amazon.Lambda.Serialization.SystemTextJson 10 | nuget Amazon.Lambda.TestUtilities 11 | nuget coverlet.collector 12 | nuget Fable.Core 13 | nuget Fable.Promise 14 | nuget FSharp.Core 15 | nuget Hedgehog 16 | nuget Microsoft.Azure.Functions.Worker 17 | nuget Microsoft.Azure.Functions.Worker.Extensions.Http 18 | nuget Microsoft.Azure.Functions.Worker.Sdk 19 | nuget Microsoft.NET.Test.Sdk 20 | nuget Ply 21 | nuget Pulumi.Automation 22 | nuget Pulumi.Aws 23 | nuget Pulumi.AzureNative 24 | nuget Pulumi.FSharp 25 | nuget Pulumi.Github 26 | nuget Pulumi.Random 27 | nuget SchlenkR.FsHttp 28 | nuget Thoth.Json 29 | nuget Thoth.Json.Net 30 | nuget Unquote 31 | nuget xunit 32 | nuget xunit.runner.visualstudio --------------------------------------------------------------------------------