diff --git a/FsCloudInit/Builders.fs b/FsCloudInit/Builders.fs index 6cecad4..14e7d08 100644 --- a/FsCloudInit/Builders.fs +++ b/FsCloudInit/Builders.fs @@ -102,6 +102,91 @@ module Builders = let aptSource = AptSourceBuilder() + /// Builder for a User. + type UserBuilder() = + member _.Yield _ = User.Default + + [] + member _.Name(user: User, name: string) = { user with Name = name } + + [] + member _.ExpireDate(user: User, expireDate: DateTimeOffset) = + { user with + ExpiredDate = expireDate.ToString("yyyy-MM-dd") } + + [] + member _.Gecos(user: User, gecos: string) = { user with Gecos = gecos } + + [] + member _.Groups(user: User, groups: string seq) = { user with Groups = groups } + + [] + member _.HomeDir(user: User, homedir: string) = { user with HomeDir = homedir } + + [] + member _.InactiveInDays(user: User, days: int) = { user with Inactive = Nullable(days) } + + [] + member _.LockPasswd(user: User, lockPasswd: bool) = { user with LockPasswd = lockPasswd } + + [] + member _.NoCreateHome(user: User, noCreateHome: bool) = + { user with + NoCreateHome = noCreateHome } + + [] + member _.NoLogInit(user: User, noLogInit: bool) = { user with NoLogInit = noLogInit } + + [] + member _.NoUserGroup(user: User, noUserGroup: bool) = { user with NoUserGroup = noUserGroup } + + [] + member _.CreateGroups(user: User, createGroups: bool) = + { user with + CreateGroups = createGroups } + + [] + member _.PrimaryGroup(user: User, primaryGroup: string) = + { user with + PrimaryGroup = primaryGroup } + + [] + member _.SelinuxUser(user: User, selinuxUser: string) = { user with SelinuxUser = selinuxUser } + + [] + member _.Shell(user: User, shell: string) = { user with Shell = shell } + + [] + member _.SshAuthorizedKeys(user: User, sshAuthorizedKeys: string seq) = + { user with + SshAuthorizedKeys = Seq.append user.SshAuthorizedKeys sshAuthorizedKeys } + + [] + member _.SshImportId(user: User, sshImportIds: string seq) = + { user with + SshImportId = Seq.append user.SshImportId sshImportIds } + + [] + member _.SshImportGitHubId(user: User, gitHubId: string) = + { user with + SshImportId = Seq.append user.SshImportId [ $"gh:{gitHubId}" ] } + + [] + member _.SshRedirectUser(user: User, sshRedirectUser: bool) = + { user with + SshRedirectUser = sshRedirectUser } + + [] + member _.System(user: User, system: bool) = { user with System = system } + + [] + member _.Sudo(user: User, sudo: string) = { user with Sudo = sudo } + + [] + member _.Uid(user: User, uid: int) = { user with Uid = Nullable(uid) } + + let user = UserBuilder() + /// Builder for a CloudConfig record. type CloudConfigBuilder() = member _.Yield _ = CloudConfig.Default @@ -163,4 +248,9 @@ module Builders = |> RunCmd |> Some } + [] + member _.Users(cloudConfig: CloudConfig, users: User seq) = + { cloudConfig with + Users = Seq.append cloudConfig.Users users } + let cloudConfig = CloudConfigBuilder() diff --git a/FsCloudInit/CloudConfig.fs b/FsCloudInit/CloudConfig.fs index a68db74..9d890a6 100644 --- a/FsCloudInit/CloudConfig.fs +++ b/FsCloudInit/CloudConfig.fs @@ -2,6 +2,7 @@ open System open System.Collections.Generic +open YamlDotNet.Serialization module FileEncoding = [] @@ -38,12 +39,22 @@ type FilePermissions = Others = enum (int (((num % 10u) - (num % 1u)) / 1u)) } | false, _ -> invalidArg "string" "Malformed permission flags." +module Sudo = + /// Defines sudo options as "ALL=(ALL) NOPASSWD:ALL" + let AllPermsNoPasswd = "ALL=(ALL) NOPASSWD:ALL" + +module internal Serialization = + let serializableSeq sequence = + if Seq.isEmpty sequence then null else ResizeArray sequence + + let defaultIfTrue b = if b then Unchecked.defaultof<_> else b + type WriteFile = { Encoding: string Content: string Owner: string Path: string - [] + [] Permissions: string Append: bool Defer: bool } @@ -90,6 +101,59 @@ type RunCmd = match this with | RunCmd commands -> commands |> Seq.map Seq.ofList +type User = + { Name: string + ExpiredDate: string + Gecos: string + Groups: string seq + HomeDir: string + Inactive: Nullable + LockPasswd: bool + NoCreateHome: bool + NoLogInit: bool + NoUserGroup: bool + CreateGroups: bool + PrimaryGroup: string + SelinuxUser: string + Shell: string + SshAuthorizedKeys: string seq + SshImportId: string seq + SshRedirectUser: bool + System: bool + Sudo: string + Uid: Nullable } + + static member Default = + { Name = null + ExpiredDate = null + Gecos = null + Groups = [] + HomeDir = null + Inactive = Nullable() + LockPasswd = true + NoCreateHome = false + NoLogInit = false + NoUserGroup = false + CreateGroups = true + PrimaryGroup = null + SelinuxUser = null + Shell = null + SshAuthorizedKeys = [] + SshImportId = [] + SshRedirectUser = false + System = false + Sudo = null + Uid = Nullable() } + + [] + member this.Model = + { this with + CreateGroups = Serialization.defaultIfTrue this.CreateGroups + Groups = Serialization.serializableSeq this.Groups + LockPasswd = Serialization.defaultIfTrue this.LockPasswd + SshAuthorizedKeys = Serialization.serializableSeq this.SshAuthorizedKeys + SshImportId = Serialization.serializableSeq this.SshImportId } + type CloudConfig = { Apt: Apt option FinalMessage: string option @@ -98,6 +162,7 @@ type CloudConfig = PackageUpgrade: bool option PackageRebootIfRequired: bool option RunCmd: RunCmd option + Users: User seq WriteFiles: WriteFile seq } static member Default = @@ -108,21 +173,20 @@ type CloudConfig = PackageUpgrade = None PackageRebootIfRequired = None RunCmd = None + Users = [] WriteFiles = [] } member this.ConfigModel = {| Apt = this.Apt |> Option.defaultValue Unchecked.defaultof FinalMessage = this.FinalMessage |> Option.toObj - Packages = - if this.Packages |> Seq.isEmpty then - null - else - this.Packages |> Seq.map (fun p -> p.Model) + Packages = this.Packages |> Seq.map (fun p -> p.Model) |> Serialization.serializableSeq PackageUpdate = this.PackageUpdate |> Option.toNullable PackageUpgrade = this.PackageUpgrade |> Option.toNullable Runcmd = this.RunCmd |> Option.map (fun runCmd -> runCmd.Model) |> Option.toObj - WriteFiles = - if this.WriteFiles |> Seq.isEmpty then - null - else - this.WriteFiles |} + Users = + let users = + this.Users |> Seq.map (fun u -> box u.Model) |> Serialization.serializableSeq + if not <| isNull users then // Include the default user created by the cloud platform + users.Insert(0, "default") + users + WriteFiles = this.WriteFiles |> Serialization.serializableSeq |} diff --git a/FsCloudInitTests/BuilderTests.fs b/FsCloudInitTests/BuilderTests.fs index 0f72cfe..9a6f769 100644 --- a/FsCloudInitTests/BuilderTests.fs +++ b/FsCloudInitTests/BuilderTests.fs @@ -125,4 +125,19 @@ let tests = } |> Writer.write |> matchExpectedAt "run-command.yaml" + } + test "Create users" { + cloudConfig { + users [ + user { + name "itme" + gecos "My Account" + ssh_import_github_id "mygithubusername" + groups [ "sudo" ] + sudo Sudo.AllPermsNoPasswd + } + ] + } + |> Writer.write + |> matchExpectedAt "users.yaml" } ] diff --git a/FsCloudInitTests/TestContent/users.yaml b/FsCloudInitTests/TestContent/users.yaml new file mode 100644 index 0000000..8e333d0 --- /dev/null +++ b/FsCloudInitTests/TestContent/users.yaml @@ -0,0 +1,10 @@ +#cloud-config +users: +- default +- name: itme + gecos: My Account + groups: + - sudo + ssh_import_id: + - gh:mygithubusername + sudo: ALL=(ALL) NOPASSWD:ALL diff --git a/README.md b/README.md index d46931b..c545636 100644 --- a/README.md +++ b/README.md @@ -212,3 +212,19 @@ cloudConfig { } |> Writer.write ``` + +#### Create additional users + +```f# +cloudConfig { + users [ + user { + name "itme" + gecos "My Account" + ssh_import_github_id "mygithubusername" + groups [ "sudo" ] + sudo Sudo.AllPermsNoPasswd + } + ] +} +```