Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

client-go: the channels are usable and we can send/recieve messages

+236 -28
+34
client-go/log.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/nshafer/phx" 7 + "github.com/rs/zerolog/log" 8 + ) 9 + 10 + // type Logger interface { 11 + // Print(level LoggerLevel, kind string, v ...any) 12 + // Println(level LoggerLevel, kind string, v ...any) 13 + // Printf(level LoggerLevel, kind string, format string, v ...any) 14 + // } 15 + 16 + type logger struct{} 17 + 18 + func (l *logger) Print(level phx.LoggerLevel, kind string, v ...any) { l.Println(level, kind, v) } 19 + func (l *logger) Println(level phx.LoggerLevel, kind string, v ...any) { 20 + switch level { 21 + case phx.LogDebug: 22 + log.Debug().Msg(fmt.Sprintf("%v", v)) 23 + case phx.LogInfo: 24 + log.Info().Msg(fmt.Sprintf("%v", v)) 25 + case phx.LogWarning: 26 + log.Warn().Msg(fmt.Sprintf("%v", v)) 27 + case phx.LogError: 28 + log.Error().Msg(fmt.Sprintf("%v", v)) 29 + } 30 + } 31 + 32 + func (l *logger) Printf(level phx.LoggerLevel, kind string, format string, v ...any) { 33 + l.Println(level, kind, fmt.Sprintf(format, v...)) 34 + }
+145 -5
client-go/main.go
··· 4 4 "fmt" 5 5 "net/url" 6 6 "os" 7 + "strings" 7 8 9 + "github.com/golang-jwt/jwt/v5" 8 10 "github.com/knadh/koanf/parsers/toml/v2" 9 11 "github.com/knadh/koanf/providers/file" 10 12 "github.com/knadh/koanf/providers/posflag" 11 13 "github.com/knadh/koanf/v2" 14 + "github.com/nshafer/phx" 12 15 "github.com/rs/zerolog" 13 16 "github.com/rs/zerolog/log" 14 17 15 18 flag "github.com/spf13/pflag" 16 19 ) 17 20 21 + type config struct { 22 + endpoint url.URL 23 + } 24 + 18 25 var conf = koanf.Conf{} 19 26 20 27 var kConfig = koanf.NewWithConf(conf) ··· 27 34 fmt.Println(flags.FlagUsages()) 28 35 os.Exit(0) 29 36 } 37 + // TODO fix default config path 30 38 flags.StringSlice("config", []string{"./dev-client.toml"}, "path to one or more toml config files") 39 + flags.String("bootstrap-token-file", "", "bootstrap token") 40 + // TODO fix default name and seed_type 41 + flags.String("name", "", "seed name") 42 + flags.String("type", "", "seed type") 31 43 flags.Bool("debug", false, "enable debug logging") 32 44 flags.Parse(os.Args[1:]) 33 45 ··· 50 62 if kConfig.Bool("debug") { 51 63 zerolog.SetGlobalLevel(zerolog.DebugLevel) 52 64 } 53 - run(kConfig) 65 + 66 + if kConfig.String("bootstrap-token-file") == "" { 67 + log.Error().Msg("Missing required bootstrap-token") 68 + os.Exit(1) 69 + } 70 + bootstrapToken, err := readSecret(kConfig.String("bootstrap-token-file")) 71 + if err != nil { 72 + log.Error().Msgf("failed to read secret, %v", err) 73 + os.Exit(1) 74 + } 75 + token, err := signToken(bootstrapToken, kConfig.String("name"), kConfig.String("type")) 76 + if err != nil { 77 + log.Error().Msgf("failed to sign jwt, %v", err) 78 + os.Exit(1) 79 + } 80 + 81 + endpoint, err := url.Parse(fmt.Sprintf("%s/client?token=%s", kConfig.String("url"), token)) 82 + if err != nil { 83 + log.Error().Msgf("failed to parse URL, %v", err) 84 + os.Exit(1) 85 + } 86 + 87 + config := config{ 88 + endpoint: *endpoint, 89 + } 90 + 91 + run(config) 54 92 } 55 93 56 - func run(kConfig *koanf.Koanf) { 94 + func run(config config) { 57 95 log.Info().Msg("Starting") 58 - log.Debug().Any("config", kConfig).Msg("") 96 + log.Debug().Any("config", config).Msg("") 97 + 98 + socket := phx.NewSocket(&config.endpoint) 99 + zerologLogger := logger{} 100 + socket.Logger = &zerologLogger 101 + 102 + // Wait for the socket to connect before continuing. If it's not able to, it will keep 103 + // retrying forever. 104 + cont := make(chan bool) 105 + socket.OnOpen(func() { 106 + cont <- true 107 + }) 108 + socket.OnError(func(err error) { 109 + log.Error().Err(err).Msg("failed to open socket connection") 110 + }) 111 + 112 + // Tell the socket to connect (or start retrying until it can connect) 113 + err := socket.Connect() 114 + if err != nil { 115 + log.Error().Err(err).Msg("failed to connect to server") 116 + } 59 117 60 - endPoint, _ := url.Parse(fmt.Sprintf("%s/client/websocket", kConfig.String("url"))) 61 - log.Debug().Any("endpoint", endPoint).Msg("") 118 + // Wait for the connection 119 + <-cont 120 + 121 + tree_id, err := joinLobby(socket) 122 + if err != nil { 123 + log.Error().Err(err).Msg("failed to join lobby") 124 + } 125 + 126 + dedicatedChannel, err := joinDedicated(socket, tree_id) 127 + if err != nil { 128 + log.Error().Err(err).Msg("failed to join dedicated channel") 129 + } 130 + 131 + seedPush, err := dedicatedChannel.Push("seed:submit", map[string]any{"name": "blank", "seed_type": "nixos", "out_path": "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaabb-nixos-system-blank-24.11.20240716.ad0b5ee"}) 132 + if err != nil { 133 + log.Error().Err(err).Msg("failed to push seed:submit") 134 + } 135 + seedPush.Receive("ok", func(response any) { 136 + log.Info().Msgf("%v", response.(map[string]interface{})["seed_id"].(string)) 137 + }) 138 + 139 + select {} 140 + } 141 + 142 + func joinLobby(socket *phx.Socket) (string, error) { 143 + cont := make(chan bool) 144 + treeChan := make(chan string) 145 + channel := socket.Channel("client:all", nil) 146 + 147 + // server sends this message immediately after join, so subscribe prior to joining 148 + channel.On("tree:id", func(response any) { 149 + treeChan <- response.(map[string]interface{})["tree_id"].(string) 150 + }) 151 + 152 + join, err := channel.Join() 153 + if err != nil { 154 + return "", fmt.Errorf("failed to join client:all") 155 + } 156 + 157 + // ensure successfully joined 158 + join.Receive("ok", func(response any) { 159 + cont <- true 160 + }) 161 + 162 + <-cont 163 + 164 + return <-treeChan, nil 165 + } 166 + 167 + func joinDedicated(socket *phx.Socket, tree_id string) (*phx.Channel, error) { 168 + cont := make(chan bool) 169 + channelName := fmt.Sprintf("client:%s", tree_id) 170 + channel := socket.Channel(channelName, map[string]string{"tree_id": tree_id}) 171 + 172 + join, err := channel.Join() 173 + if err != nil { 174 + return nil, fmt.Errorf("failed to join %s", channelName) 175 + } 176 + 177 + join.Receive("ok", func(response any) { 178 + cont <- true 179 + }) 180 + 181 + <-cont 182 + 183 + return channel, nil 184 + } 185 + 186 + func readSecret(path string) (string, error) { 187 + content, err := os.ReadFile(path) 188 + if err != nil { 189 + return "", err 190 + } 191 + 192 + return strings.TrimSpace(string(content)), nil 193 + } 194 + 195 + func signToken(bootstrapToken, name, seedType string) (string, error) { 196 + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 197 + "name": name, 198 + "seed_type": seedType, 199 + }) 200 + 201 + return token.SignedString([]byte(bootstrapToken)) 62 202 }
+19
config/runtime.exs
··· 29 29 } 30 30 } 31 31 }, 32 + "bootstrap_token_file" => %{ 33 + "type" => "string" 34 + }, 32 35 "clients" => %{ 33 36 "type" => "object", 34 37 "properties" => %{ ··· 221 224 222 225 :error -> 223 226 Logger.warning("Configuration is missing `auth.oidc_client_secret_file`.") 227 + Kernel.exit(1) 228 + end 229 + 230 + # bootstrap token file 231 + json_config = 232 + with {:ok, bootstrap_token_file} <- json_config |> Keyword.fetch(:bootstrap_token_file), 233 + {:ok, bootstrap_token} <- read_credential(bootstrap_token_file) do 234 + json_config 235 + |> Keyword.put(:bootstrap_token, bootstrap_token) 236 + else 237 + {:error, err} -> 238 + Logger.warning("Failed to load bootstrap_token from secret file, #{err}.") 239 + Kernel.exit(1) 240 + 241 + :error -> 242 + Logger.warning("Configuration is missing `bootstrap_token_file`.") 224 243 Kernel.exit(1) 225 244 end 226 245
+1
dev-client.toml
··· 1 1 url = "ws://localhost:4000" 2 2 name = "blank" 3 3 type = "home-manager" 4 + bootstrap-token-file = "./.bootstrap.token"
+1
dev-server.json
··· 1 1 { 2 2 "public_url": "http://localhost:4000", 3 + "bootstrap_token_file": "./.bootstrap.token", 3 4 "auth": { 4 5 "oidc_base_url": "https://id.junco.dev/oauth2/openid/sower-dev", 5 6 "oidc_client_id": "sower-dev",
+1
flake.nix
··· 83 83 pkgs.nvfetcher 84 84 pkgs.process-compose 85 85 pkgs.postgresql 86 + pkgs.watchexec 86 87 ] 87 88 ++ lib.optionals pkgs.stdenv.isLinux [ 88 89 # elixir
+3
go.mod
··· 3 3 go 1.22.5 4 4 5 5 require ( 6 + github.com/golang-jwt/jwt/v5 v5.2.1 6 7 github.com/knadh/koanf/parsers/toml/v2 v2.1.0 7 8 github.com/knadh/koanf/providers/file v1.0.0 8 9 github.com/knadh/koanf/providers/posflag v0.1.0 9 10 github.com/knadh/koanf/v2 v2.1.1 11 + github.com/nshafer/phx v0.2.2 10 12 github.com/rs/zerolog v1.33.0 11 13 github.com/spf13/pflag v1.0.5 12 14 ) ··· 14 16 require ( 15 17 github.com/fsnotify/fsnotify v1.7.0 // indirect 16 18 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect 19 + github.com/gorilla/websocket v1.5.0 // indirect 17 20 github.com/knadh/koanf/maps v0.1.1 // indirect 18 21 github.com/mattn/go-colorable v0.1.13 // indirect 19 22 github.com/mattn/go-isatty v0.0.19 // indirect
+6
go.sum
··· 7 7 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= 8 8 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 9 9 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 10 + github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 11 + github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 12 + github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 13 + github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 10 14 github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= 11 15 github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 12 16 github.com/knadh/koanf/parsers/toml/v2 v2.1.0 h1:EUdIKIeezfDj6e1ABDhIjhbURUpyrP1HToqW6tz8R0I= ··· 26 30 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 27 31 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 28 32 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 33 + github.com/nshafer/phx v0.2.2 h1:rtVGSrQz7i/Pg7P2yxg7kc8IZ08WKRlc21yNE47KU1A= 34 + github.com/nshafer/phx v0.2.2/go.mod h1:YkYF7ulSMG5nJnxu4nMYT7qQqIZ+1bOd36cY5RqBYD8= 29 35 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 30 36 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 31 37 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+6
justfile
··· 33 33 git push --tags 34 34 35 35 start: 36 + iex -S mix phx.server 37 + 38 + start-pry: 36 39 iex --dbg pry -S mix phx.server 40 + 41 + start-client: 42 + watchexec --watch client-go --restart -- go run ./client-go --debug 37 43 38 44 test: 39 45 nix build .#checks.x86_64-linux.default --print-build-logs
+11 -21
lib/sower_web/client_channel.ex
··· 32 32 end 33 33 34 34 def handle_in( 35 - "seed:sync", 35 + "seed:submit", 36 36 %{ 37 - # "booted_seed" => booted_seed, 38 - # "current_seed" => current_seed, 39 - # "profile_seed" => profile_seed 40 - }, 37 + "name" => name, 38 + "seed_type" => seed_type, 39 + "out_path" => out_path 40 + } = seed, 41 41 socket 42 42 ) do 43 - _tree = 44 - Sower.Tree.by_id(socket.assigns.tree_id) 45 - |> Ash.load([:booted_seed, :current_seed, :profile_seed, :latest_seed]) 46 - 47 - # res = 48 - # case Sower.Tree.set_system_seeds( 49 - # tree, 50 - # profile_seed["id"], 51 - # booted_seed["id"], 52 - # current_seed["id"] 53 - # ) 54 - # |> dbg() do 55 - # {:ok, _} -> {:reply, {:ok, "yes"}, socket} 56 - # {:error, _} -> {:reply, {:error, "fail"}, socket} 57 - # end 43 + case Sower.Seed.new(name, seed_type, out_path, nil, nil) do 44 + {:ok, %Sower.Seed{} = seed} -> 45 + {:reply, {:ok, %{seed_id: seed.id}}, socket} 58 46 59 - {:reply, {:ok, "TODO"}, socket} 47 + {:error, _err} -> 48 + {:reply, {:error, "failed to submit"}, socket} 49 + end 60 50 end 61 51 62 52 def handle_info(:push_tree_id_to_client, socket) do
+9 -2
lib/sower_web/client_socket.ex
··· 9 9 bootstrap_token = 10 10 case Application.fetch_env(:sower, :bootstrap_token) do 11 11 {:ok, token} -> token 12 - :error -> Kernel.exit(:no_bootstrap_token) 12 + :error -> Kernel.exit(:config_no_bootstrap_token) 13 13 end 14 14 15 15 signer = Joken.Signer.create("HS256", bootstrap_token) ··· 22 22 end 23 23 24 24 _ -> 25 + Logger.error("failed to verify client token") 25 26 {:error, "unauthorized"} 26 27 end 27 28 end 28 29 30 + def connect(%{}, _socket, _connect_info) do 31 + {:error, "unauthorized. authentication token required"} 32 + end 33 + 29 34 @impl true 30 35 def id(_socket), do: nil 31 36 32 37 # TODO: use id provided by claims 33 38 defp get_tree(%{"name" => name, "seed_type" => seed_type}) do 34 - case res = Sower.Tree.find(name, seed_type) |> dbg() do 39 + Logger.debug(~s"Connection from [#{name}] of type [#{seed_type}]") 40 + 41 + case res = Sower.Tree.find(name, seed_type) do 35 42 {:error, %Ash.Error.Query.NotFound{}} -> Sower.Tree.register(name, seed_type) 36 43 _ -> res 37 44 end