├── .github └── workflows │ ├── go.yml │ ├── golangci-lint.yml │ └── nix.yml ├── LICENSE ├── cmd ├── build.go ├── eval.go ├── fetcher.go ├── list.go ├── root.go ├── run.go └── status.go ├── docs ├── advanced-config.md ├── authentication.md ├── contribute.md ├── design.md ├── generated-module-options.md └── howtos.md ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── internal ├── builder │ ├── builder.go │ ├── builder_test.go │ ├── exec.go │ ├── exec_test.go │ └── generation.go ├── config │ ├── config.go │ ├── config_test.go │ ├── configuration.yaml │ └── secret ├── deployer │ ├── deployer.go │ ├── deployer_test.go │ ├── post_deployment_command.go │ └── post_deployment_command_test.go ├── executor │ ├── executor.go │ ├── nix.go │ └── utils.go ├── fetcher │ ├── fetcher.go │ └── fetcher_test.go ├── http │ └── http.go ├── manager │ ├── manager.go │ └── manager_test.go ├── profile │ ├── profile.go │ └── profile_test.go ├── prometheus │ └── prometheus.go ├── repository │ ├── fail.public │ ├── git.go │ ├── git_test.go │ ├── invalid.public │ ├── repository.go │ ├── repository_status.go │ ├── repository_test.go │ ├── test.private │ └── test.public ├── scheduler │ └── scheduler.go ├── store │ ├── store.go │ └── store_test.go ├── types │ └── types.go └── utils │ ├── reboot.go │ ├── testing.go │ ├── utils.go │ └── utils_test.go ├── main.go ├── nix ├── module-options-doc.nix ├── module-options.nix ├── module.nix └── package.nix └── readme.md /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.22' 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: stable 20 | - name: golangci-lint 21 | uses: golangci/golangci-lint-action@v7 22 | with: 23 | version: v2.0 24 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | name: "Nix" 2 | on: 3 | pull_request: 4 | push: 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: cachix/install-nix-action@v25 11 | with: 12 | nix_path: nixpkgs=channel:nixos-unstable 13 | - uses: DeterminateSystems/magic-nix-cache-action@v2 14 | - run: nix flake check 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2023] [Antoine Eiche] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/build.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nlewo/comin/internal/executor" 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var buildCmd = &cobra.Command{ 12 | Use: "build", 13 | Short: "Build a machine configuration", 14 | Args: cobra.MinimumNArgs(0), 15 | Run: func(cmd *cobra.Command, args []string) { 16 | ctx := context.TODO() 17 | hosts := make([]string, 1) 18 | executor, _ := executor.NewNixExecutor() 19 | if hostname != "" { 20 | hosts[0] = hostname 21 | } else { 22 | hosts, _ = executor.List(flakeUrl) 23 | } 24 | for _, host := range hosts { 25 | logrus.Infof("Building the NixOS configuration of machine '%s'", host) 26 | 27 | drvPath, _, err := executor.ShowDerivation(ctx, flakeUrl, host) 28 | if err != nil { 29 | logrus.Errorf("Failed to evaluate the configuration '%s': '%s'", host, err) 30 | } 31 | err = executor.Build(ctx, drvPath) 32 | if err != nil { 33 | logrus.Errorf("Failed to build the configuration '%s': '%s'", host, err) 34 | } 35 | } 36 | }, 37 | } 38 | 39 | func init() { 40 | buildCmd.Flags().StringVarP(&hostname, "hostname", "", "", "the name of the configuration to build") 41 | buildCmd.Flags().StringVarP(&flakeUrl, "flake-url", "", ".", "the URL of the flake") 42 | rootCmd.AddCommand(buildCmd) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/eval.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nlewo/comin/internal/executor" 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var evalCmd = &cobra.Command{ 12 | Use: "eval", 13 | Short: "Eval a machine from a local repository", 14 | Args: cobra.MinimumNArgs(0), 15 | Run: func(cmd *cobra.Command, args []string) { 16 | hosts := make([]string, 1) 17 | ctx := context.TODO() 18 | executor, _ := executor.NewNixExecutor() 19 | if hostname != "" { 20 | hosts[0] = hostname 21 | } else { 22 | hosts, _ = executor.List(flakeUrl) 23 | } 24 | for _, host := range hosts { 25 | logrus.Infof("Evaluating the NixOS configuration of machine '%s'", host) 26 | _, _, err := executor.ShowDerivation(ctx, flakeUrl, host) 27 | if err != nil { 28 | logrus.Errorf("Failed to eval the configuration '%s': '%s'", host, err) 29 | } 30 | } 31 | }, 32 | } 33 | 34 | func init() { 35 | evalCmd.Flags().StringVarP(&hostname, "hostname", "", "", "the name of the configuration to eval") 36 | evalCmd.Flags().StringVarP(&flakeUrl, "flake-url", "", ".", "the URL of the flake") 37 | rootCmd.AddCommand(evalCmd) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/fetcher.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var fetchCmd = &cobra.Command{ 11 | Use: "fetch", 12 | Short: "Trigger a fetch of all Git remotes", 13 | Args: cobra.MinimumNArgs(0), 14 | Run: func(cmd *cobra.Command, args []string) { 15 | url := "http://localhost:4242/api/fetcher/fetch" 16 | client := http.Client{ 17 | Timeout: time.Second * 2, 18 | } 19 | req, err := http.NewRequest(http.MethodPost, url, nil) 20 | if err != nil { 21 | return 22 | } 23 | _, _ = client.Do(req) 24 | }, 25 | } 26 | 27 | func init() { 28 | rootCmd.AddCommand(fetchCmd) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nlewo/comin/internal/executor" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var listCmd = &cobra.Command{ 11 | Use: "list", 12 | Short: "List hosts of the local repository", 13 | Args: cobra.MinimumNArgs(0), 14 | Run: func(cmd *cobra.Command, args []string) { 15 | executor, _ := executor.NewNixExecutor() 16 | hosts, _ := executor.List(flakeUrl) 17 | for _, host := range hosts { 18 | fmt.Println(host) 19 | } 20 | }, 21 | } 22 | 23 | func init() { 24 | rootCmd.AddCommand(listCmd) 25 | listCmd.Flags().StringVarP(&flakeUrl, "flake-url", "", ".", "the URL of the flake") 26 | } 27 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 lewo 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "os" 10 | ) 11 | 12 | var debug bool 13 | var hostname string 14 | var flakeUrl string 15 | 16 | // Set at build time 17 | var version = "0.0.0" 18 | 19 | var rootCmd = &cobra.Command{ 20 | Use: "comin", 21 | Short: "GitOps For NixOS Machines", 22 | Version: version, 23 | } 24 | 25 | func Execute() { 26 | err := rootCmd.Execute() 27 | if err != nil { 28 | os.Exit(1) 29 | } 30 | } 31 | 32 | func init() { 33 | rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { 34 | if debug { 35 | logrus.Info("Debug logs enabled") 36 | logrus.SetLevel(logrus.DebugLevel) 37 | } 38 | } 39 | rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "verbose logging") 40 | } 41 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "time" 7 | 8 | "github.com/nlewo/comin/internal/builder" 9 | "github.com/nlewo/comin/internal/config" 10 | "github.com/nlewo/comin/internal/deployer" 11 | "github.com/nlewo/comin/internal/executor" 12 | "github.com/nlewo/comin/internal/fetcher" 13 | "github.com/nlewo/comin/internal/http" 14 | "github.com/nlewo/comin/internal/manager" 15 | "github.com/nlewo/comin/internal/prometheus" 16 | "github.com/nlewo/comin/internal/repository" 17 | "github.com/nlewo/comin/internal/scheduler" 18 | store "github.com/nlewo/comin/internal/store" 19 | "github.com/nlewo/comin/internal/utils" 20 | "github.com/sirupsen/logrus" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | var configFilepath string 25 | 26 | var runCmd = &cobra.Command{ 27 | Use: "run", 28 | Short: "Run comin to deploy your published configurations", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | cfg, err := config.Read(configFilepath) 31 | if err != nil { 32 | logrus.Error(err) 33 | os.Exit(1) 34 | } 35 | gitConfig := config.MkGitConfig(cfg) 36 | 37 | machineId, err := utils.ReadMachineId() 38 | if err != nil { 39 | logrus.Error(err) 40 | os.Exit(1) 41 | } 42 | 43 | metrics := prometheus.New() 44 | storeFilename := path.Join(cfg.StateDir, "store.json") 45 | store := store.New(storeFilename, 10, 10) 46 | if err := store.Load(); err != nil { 47 | logrus.Errorf("Ignoring the state file %s because of the loading error: %s", storeFilename, err) 48 | } 49 | metrics.SetBuildInfo(cmd.Version) 50 | 51 | // We get the last mainCommitId to avoid useless 52 | // redeployment as well as non fast forward checkouts 53 | var mainCommitId string 54 | var lastDeployment *deployer.Deployment 55 | if ok, ld := store.LastDeployment(); ok { 56 | mainCommitId = ld.Generation.MainCommitId 57 | lastDeployment = &ld 58 | } 59 | repository, err := repository.New(gitConfig, mainCommitId, metrics) 60 | if err != nil { 61 | logrus.Errorf("Failed to initialize the repository: %s", err) 62 | os.Exit(1) 63 | } 64 | 65 | fetcher := fetcher.NewFetcher(repository) 66 | fetcher.Start() 67 | sched := scheduler.New() 68 | sched.FetchRemotes(fetcher, cfg.Remotes) 69 | 70 | executor, err := executor.New() 71 | if err != nil { 72 | logrus.Error("Failed to create executor") 73 | return 74 | } 75 | 76 | builder := builder.New(gitConfig.Path, gitConfig.Dir, cfg.Hostname, 30*time.Minute, executor.Eval, 30*time.Minute, executor.Build) 77 | deployer := deployer.New(executor.Deploy, lastDeployment, cfg.PostDeploymentCommand) 78 | 79 | manager := manager.New(store, metrics, sched, fetcher, builder, deployer, machineId) 80 | 81 | http.Serve(manager, 82 | metrics, 83 | cfg.ApiServer.ListenAddress, cfg.ApiServer.Port, 84 | cfg.Exporter.ListenAddress, cfg.Exporter.Port) 85 | manager.Run() 86 | }, 87 | } 88 | 89 | func init() { 90 | runCmd.PersistentFlags().StringVarP(&configFilepath, "config", "", "", "the configuration file path") 91 | _ = runCmd.MarkPersistentFlagRequired("config") 92 | rootCmd.AddCommand(runCmd) 93 | } 94 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/dustin/go-humanize" 11 | 12 | "github.com/nlewo/comin/internal/builder" 13 | "github.com/nlewo/comin/internal/deployer" 14 | "github.com/nlewo/comin/internal/manager" 15 | "github.com/sirupsen/logrus" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var statusOneline bool 20 | 21 | func getStatus() (status manager.State, err error) { 22 | url := "http://localhost:4242/api/status" 23 | client := http.Client{ 24 | Timeout: time.Second * 2, 25 | } 26 | req, err := http.NewRequest(http.MethodGet, url, nil) 27 | if err != nil { 28 | return 29 | } 30 | res, err := client.Do(req) 31 | if err != nil { 32 | return 33 | } 34 | if res.Body != nil { 35 | defer res.Body.Close() // nolint 36 | } 37 | body, err := io.ReadAll(res.Body) 38 | if err != nil { 39 | return 40 | } 41 | err = json.Unmarshal(body, &status) 42 | if err != nil { 43 | return 44 | } 45 | return 46 | } 47 | 48 | func longStatus(status manager.State) { 49 | fmt.Printf("Status of the machine %s\n", status.Builder.Hostname) 50 | if status.NeedToReboot { 51 | fmt.Printf(" Need to reboot: yes\n") 52 | } 53 | fmt.Printf(" Fetcher\n") 54 | if status.Fetcher.RepositoryStatus.SelectedCommitShouldBeSigned { 55 | if status.Fetcher.RepositoryStatus.SelectedCommitSigned { 56 | fmt.Printf(" Commit %s signed by %s\n", status.Fetcher.RepositoryStatus.SelectedCommitId, status.Fetcher.RepositoryStatus.SelectedCommitSignedBy) 57 | } else { 58 | fmt.Printf(" Commit %s is not signed while it should be\n", status.Fetcher.RepositoryStatus.SelectedCommitId) 59 | } 60 | } 61 | for _, r := range status.Fetcher.RepositoryStatus.Remotes { 62 | fmt.Printf(" Remote %s %s fetched %s\n", 63 | r.Name, r.Url, humanize.Time(r.FetchedAt), 64 | ) 65 | } 66 | fmt.Printf(" Builder\n") 67 | if status.Builder.Generation != nil { 68 | builder.GenerationShow(*status.Builder.Generation) 69 | } else { 70 | fmt.Printf(" No build available\n") 71 | } 72 | status.Deployer.Show(" ") 73 | 74 | } 75 | 76 | func onelineStatus(status manager.State) { 77 | if status.Builder.IsEvaluating { 78 | fmt.Printf(" eval %s/%s (%s)", status.Builder.Generation.SelectedRemoteName, status.Builder.Generation.SelectedBranchName, 79 | humanize.Time(status.Builder.Generation.EvalStartedAt)) 80 | } else if status.Builder.IsBuilding { 81 | fmt.Printf(" build %s/%s (%s)", status.Builder.Generation.SelectedRemoteName, status.Builder.Generation.SelectedBranchName, 82 | humanize.Time(status.Builder.Generation.BuildStartedAt)) 83 | } else if status.Builder.Generation.EvalStatus == builder.EvalFailed { 84 | fmt.Printf(" %s/%s (%s)", status.Builder.Generation.SelectedRemoteName, status.Builder.Generation.SelectedBranchName, 85 | humanize.Time(status.Builder.Generation.EvalEndedAt)) 86 | } else if status.Builder.Generation.BuildStatus == builder.BuildFailed { 87 | fmt.Printf(" %s/%s (%s)", status.Builder.Generation.SelectedRemoteName, status.Builder.Generation.SelectedBranchName, 88 | humanize.Time(status.Builder.Generation.BuildEndedAt)) 89 | } else if status.Deployer.Deployment != nil { 90 | switch status.Deployer.Deployment.Status { 91 | case deployer.Running: 92 | fmt.Printf(" deploy %s/%s (%s)", status.Deployer.Deployment.Generation.SelectedRemoteName, status.Deployer.Deployment.Generation.SelectedBranchName, 93 | humanize.Time(status.Deployer.Deployment.EndedAt)) 94 | case deployer.Failed: 95 | fmt.Printf(" %s/%s (%s)", status.Deployer.Deployment.Generation.SelectedRemoteName, status.Deployer.Deployment.Generation.SelectedBranchName, 96 | humanize.Time(status.Deployer.Deployment.EndedAt)) 97 | case deployer.Done: 98 | fmt.Printf(" %s/%s (%s)", status.Deployer.Deployment.Generation.SelectedRemoteName, status.Deployer.Deployment.Generation.SelectedBranchName, 99 | humanize.Time(status.Deployer.Deployment.EndedAt)) 100 | } 101 | } 102 | if status.NeedToReboot { 103 | fmt.Printf(" ") 104 | } 105 | } 106 | 107 | var statusCmd = &cobra.Command{ 108 | Use: "status", 109 | Short: "Get the status of the local machine", 110 | Args: cobra.MinimumNArgs(0), 111 | Run: func(cmd *cobra.Command, args []string) { 112 | status, err := getStatus() 113 | if err != nil { 114 | logrus.Fatal(err) 115 | } 116 | if statusOneline { 117 | onelineStatus(status) 118 | } else { 119 | longStatus(status) 120 | } 121 | }, 122 | } 123 | 124 | func init() { 125 | statusCmd.PersistentFlags().BoolVarP(&statusOneline, "oneline", "", false, "oneline") 126 | rootCmd.AddCommand(statusCmd) 127 | } 128 | -------------------------------------------------------------------------------- /docs/advanced-config.md: -------------------------------------------------------------------------------- 1 | ## Advanced configuration 2 | 3 | ```nix 4 | services.comin = { 5 | enable = true; 6 | remotes = [ 7 | { 8 | name = "origin"; 9 | url = "https://gitlab.com/your/private-infra.git"; 10 | # This is an access token to access our private repository 11 | auth.access_token_path = cfg.sops.secrets."gitlab/access_token".path; 12 | # No testing branch on this remote 13 | branches.testing.name = ""; 14 | } 15 | { 16 | name = "local"; 17 | url = "/your/local/infra/repository"; 18 | # We don't want to deploy the local main branch on each commit 19 | branches.main.name = "main-tilia"; 20 | # We want to fetch this remote every 2 seconds 21 | poller.period = 2; 22 | } 23 | ]; 24 | machineId = "22823ba6c96947e78b006c51a56fd89c"; 25 | }; 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | ## Authentication for private repositories 2 | 3 | ### GitLab 4 | 5 | You need to create a [personal access 6 | token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) with the `read_repository` scope 7 | and store this token into a file (`/filepath/to/your/access/token` in the below example). 8 | 9 | ### GitHub 10 | 11 | You need to create a [fined-grained personal access 12 | token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) 13 | and store this token into a file (`/filepath/to/your/access/token` in 14 | the below example). Note classic personal access tokens are also 15 | supported. 16 | 17 | ### Use your personal access token 18 | 19 | The file path containing this access token for a remote is provided 20 | with the attribute `comin.remotes.*.auth.access_token_path`. 21 | 22 | #### Example 23 | 24 | ```nix 25 | services.comin = { 26 | enable = true; 27 | remotes = [ 28 | { 29 | name = "origin"; 30 | url = "https://gitlab.com/your/private-infra.git"; 31 | auth.access_token_path = "/filepath/to/your/access/token"; 32 | } 33 | ]; 34 | }; 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | ## How to run comin locally 2 | 3 | $ go build 4 | $ sudo ./comin run --config ./internal/config/configuration.yaml --debug 5 | 6 | You need to update the `configuration.yaml` file with your remotes. 7 | It is also possible to use the YAML configuration file generation by 8 | your module (see `systemctl show comin.service | grep ExecStart=`). 9 | -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | ## The comin commit selection algorithm 2 | 3 | comin supports several remotes and each of these remotes can have a 4 | `main` and `testing` branches. A new commit can be submitted in all of 5 | these branches and comin need to decide which one to choose. 6 | 7 | The comin goal is to 8 | - refuse commits push-forced to `main` branches 9 | - only allow `testing` branches on top of `main` branches 10 | - prefer commits from `testing` branches 11 | 12 | Here is the algorithm used to choose the next commit to deploy: 13 | 14 | 1. Fetch a subset of remotes 15 | 2. Ensure commits from updated `main` and `testing` branches are not 16 | behind the last `main` deployed commit 17 | 3. Get the first commit from `main` branches (remotes are ordered in 18 | the configuration) on top of the last deployed `main` commit. If no 19 | such commit exists, comin gets the first commit equal to the last 20 | deployed `main` commit. 21 | 4. Get the first `testing` commit on top of the previously chosen 22 | `main` commit. If no such commit exists, comin uses the previously 23 | chosen `main` commit. 24 | -------------------------------------------------------------------------------- /docs/generated-module-options.md: -------------------------------------------------------------------------------- 1 | ## services\.comin\.enable 2 | 3 | 4 | 5 | Whether to run the comin service\. 6 | 7 | 8 | 9 | *Type:* 10 | boolean 11 | 12 | 13 | 14 | *Default:* 15 | ` false ` 16 | 17 | 18 | 19 | ## services\.comin\.package 20 | 21 | 22 | 23 | The comin package to use\. 24 | 25 | 26 | 27 | *Type:* 28 | null or package 29 | 30 | 31 | 32 | *Default:* 33 | ` "pkgs.comin or comin.packages.\${system}.default or null" ` 34 | 35 | 36 | 37 | ## services\.comin\.debug 38 | 39 | Whether to run comin in debug mode\. Be careful, secrets are shown!\. 40 | 41 | 42 | 43 | *Type:* 44 | boolean 45 | 46 | 47 | 48 | *Default:* 49 | ` false ` 50 | 51 | 52 | 53 | ## services\.comin\.exporter 54 | 55 | 56 | 57 | Options for the Prometheus exporter\. 58 | 59 | 60 | 61 | *Type:* 62 | submodule 63 | 64 | 65 | 66 | *Default:* 67 | ` { } ` 68 | 69 | 70 | 71 | ## services\.comin\.exporter\.listen_address 72 | 73 | 74 | 75 | Address to listen on for the Prometheus exporter\. Empty string will listen on all interfaces\. 76 | 77 | 78 | 79 | *Type:* 80 | string 81 | 82 | 83 | 84 | *Default:* 85 | ` "" ` 86 | 87 | 88 | 89 | ## services\.comin\.exporter\.openFirewall 90 | 91 | 92 | 93 | Open port in firewall for incoming connections to the Prometheus exporter\. 94 | 95 | 96 | 97 | *Type:* 98 | boolean 99 | 100 | 101 | 102 | *Default:* 103 | ` false ` 104 | 105 | 106 | 107 | ## services\.comin\.exporter\.port 108 | 109 | 110 | 111 | Port to listen on for the Prometheus exporter\. 112 | 113 | 114 | 115 | *Type:* 116 | signed integer 117 | 118 | 119 | 120 | *Default:* 121 | ` 4243 ` 122 | 123 | 124 | 125 | ## services\.comin\.flakeSubdirectory 126 | 127 | 128 | 129 | Subdirectory in the repository, containing flake\.nix\. 130 | 131 | 132 | 133 | *Type:* 134 | string 135 | 136 | 137 | 138 | *Default:* 139 | ` "." ` 140 | 141 | 142 | 143 | ## services\.comin\.gpgPublicKeyPaths 144 | 145 | 146 | 147 | A list of GPG public key file paths\. Each of this file should contains an armored GPG key\. 148 | 149 | 150 | 151 | *Type:* 152 | list of string 153 | 154 | 155 | 156 | *Default:* 157 | ` [ ] ` 158 | 159 | 160 | 161 | ## services\.comin\.hostname 162 | 163 | 164 | 165 | The name of the NixOS configuration to evaluate and 166 | deploy\. This value is used by comin to evaluate the 167 | flake output 168 | nixosConfigurations\.“\”\.config\.system\.build\.toplevel 169 | 170 | 171 | 172 | *Type:* 173 | string 174 | 175 | 176 | 177 | *Default:* 178 | ` "the-machine-hostname" ` 179 | 180 | 181 | 182 | ## services\.comin\.machineId 183 | 184 | 185 | 186 | The expected machine-id of the machine configured by 187 | comin\. If not null, the configuration is only deployed 188 | when this specified machine-id is equal to the actual 189 | machine-id\. 190 | This is mainly useful for server migration: this allows 191 | to migrate a configuration from a machine to another 192 | machine (with different hardware for instance) without 193 | impacting both\. 194 | Note it is only used by comin at evaluation\. 195 | 196 | 197 | 198 | *Type:* 199 | null or string 200 | 201 | 202 | 203 | *Default:* 204 | ` null ` 205 | 206 | 207 | 208 | ## services\.comin\.remotes 209 | 210 | 211 | 212 | Ordered list of repositories to pull\. 213 | 214 | 215 | 216 | *Type:* 217 | list of (submodule) 218 | 219 | 220 | 221 | ## services\.comin\.remotes\.\*\.auth 222 | 223 | 224 | 225 | Authentication options\. 226 | 227 | 228 | 229 | *Type:* 230 | submodule 231 | 232 | 233 | 234 | *Default:* 235 | ` { } ` 236 | 237 | 238 | 239 | ## services\.comin\.remotes\.\*\.auth\.access_token_path 240 | 241 | 242 | 243 | The path of the auth file\. 244 | 245 | 246 | 247 | *Type:* 248 | string 249 | 250 | 251 | 252 | *Default:* 253 | ` "" ` 254 | 255 | 256 | 257 | ## services\.comin\.remotes\.\*\.branches 258 | 259 | 260 | 261 | Branches to pull\. 262 | 263 | 264 | 265 | *Type:* 266 | submodule 267 | 268 | 269 | 270 | *Default:* 271 | ` { } ` 272 | 273 | 274 | 275 | ## services\.comin\.remotes\.\*\.branches\.main 276 | 277 | 278 | 279 | The main branch to fetch\. 280 | 281 | 282 | 283 | *Type:* 284 | submodule 285 | 286 | 287 | 288 | *Default:* 289 | ` { } ` 290 | 291 | 292 | 293 | ## services\.comin\.remotes\.\*\.branches\.main\.name 294 | 295 | 296 | 297 | The name of the main branch\. 298 | 299 | 300 | 301 | *Type:* 302 | string 303 | 304 | 305 | 306 | *Default:* 307 | ` "main" ` 308 | 309 | 310 | 311 | ## services\.comin\.remotes\.\*\.branches\.testing 312 | 313 | 314 | 315 | The testing branch to fetch\. 316 | 317 | 318 | 319 | *Type:* 320 | submodule 321 | 322 | 323 | 324 | *Default:* 325 | ` { } ` 326 | 327 | 328 | 329 | ## services\.comin\.remotes\.\*\.branches\.testing\.name 330 | 331 | 332 | 333 | The name of the testing branch\. 334 | 335 | 336 | 337 | *Type:* 338 | string 339 | 340 | 341 | 342 | *Default:* 343 | ` "testing-the-machine-hostname" ` 344 | 345 | 346 | 347 | ## services\.comin\.remotes\.\*\.name 348 | 349 | 350 | 351 | The name of the remote\. 352 | 353 | 354 | 355 | *Type:* 356 | string 357 | 358 | 359 | 360 | ## services\.comin\.remotes\.\*\.poller 361 | 362 | 363 | 364 | The poller options\. 365 | 366 | 367 | 368 | *Type:* 369 | submodule 370 | 371 | 372 | 373 | *Default:* 374 | ` { } ` 375 | 376 | 377 | 378 | ## services\.comin\.remotes\.\*\.poller\.period 379 | 380 | 381 | 382 | The poller period in seconds\. 383 | 384 | 385 | 386 | *Type:* 387 | signed integer 388 | 389 | 390 | 391 | *Default:* 392 | ` 60 ` 393 | 394 | 395 | 396 | ## services\.comin\.remotes\.\*\.timeout 397 | 398 | 399 | 400 | Git fetch timeout in seconds\. 401 | 402 | 403 | 404 | *Type:* 405 | signed integer 406 | 407 | 408 | 409 | *Default:* 410 | ` 300 ` 411 | 412 | 413 | 414 | ## services\.comin\.remotes\.\*\.url 415 | 416 | 417 | 418 | The URL of the repository\. 419 | 420 | 421 | 422 | *Type:* 423 | string 424 | 425 | 426 | -------------------------------------------------------------------------------- /docs/howtos.md: -------------------------------------------------------------------------------- 1 | ## How to test a NixOS configuration change 2 | 3 | TLDR: push a commit to the `testing-` branch (rebased on the 4 | `main` branch) to deploy a change to the machine named ``. 5 | 6 | By default, each machine pulls configuration from the branch 7 | `testing-`. When this branch is on top of the `main` branch, 8 | comin deploys this configuration by running `switch-to-configuration 9 | test`: the bootloader configuration is not modified. 10 | 11 | To test a configuration: 12 | 13 | 1. Create a `testing-` branch in your configuration 14 | repository on top of the `main` branch 15 | 2. Add new commits to this branch and push it 16 | 3. comin runs `switch-to-configuration test` on the configuration: the bootload is not updated 17 | 18 | Contrary to the main branch, this branch can be hard reset but always 19 | has to be on top of the `main` branch. 20 | 21 | To `nixos-rebuild switch` to this configuration, the `main` branch has 22 | to be rebased on the `testing` branch. 23 | 24 | ## Iterate faster with local repository 25 | 26 | By default, comin polls remotes every 60 seconds. You could however 27 | add a local repository as a comin remote: comin could then poll this 28 | branch every second. When you commit to this repository, comin is 29 | starting to deploy the new configuration immediately. 30 | 31 | However, be careful because this repository could then be used by an 32 | attacker to update your machine. 33 | 34 | Example of a configuration with a local repository: 35 | 36 | ```nix 37 | services.comin = { 38 | enable = true; 39 | remotes = [ 40 | { 41 | name = "local"; 42 | url = "/your/local/infra/repository"; 43 | poller.period = 2; 44 | } 45 | ]; 46 | } 47 | ``` 48 | 49 | ## How to migrate a configuration from a machine to another one 50 | 51 | Suppose you have a running NixOS machine and you want to move this 52 | configuration to another machine while preserving the same 53 | hostname. If you use a testing branch, both of these machines will be 54 | updated when changes are pushed to the testing branch. 55 | 56 | To avoid such situation, we could set the option 57 | `services.comin.machineId`. If the machine where comin is running 58 | doesn't have this expected `machine-id` (compared to the content of 59 | the `/etc/machine-id` file), comin won't deploy the configuration. 60 | 61 | So, to migrate to another machine, you have to update this 62 | option in the `testing-` branch in order to only deploy this 63 | configuration to the new machine. 64 | 65 | ## Check Git commit signatures 66 | 67 | The option `services.comin.gpgPublicKeyPaths` allows to declare a list 68 | of GPG public keys. If `services.comin.gpgPublicKeyPaths != []`, comin **only** evaluates commits signed 69 | by one of these GPG keys. Note only the last commit needs to be signed. 70 | 71 | The file containing a GPG public key has to be created with `gpg --armor --export alice@cyb.org`. 72 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1743836471, 6 | "narHash": "sha256-ztyunXhmoh4TAgQR3dPj+utXp7TgztbsBtuutdv5qg8=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "50df425cd58d5136b0b05ad86047d405dad2203e", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "repo": "nixpkgs", 15 | "type": "github" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Comin - GitOps for NixOS Machines"; 3 | 4 | inputs.nixpkgs.url = "github:NixOS/nixpkgs"; 5 | 6 | outputs = { self, nixpkgs }: 7 | let 8 | systems = [ "aarch64-linux" "x86_64-linux" ]; 9 | forAllSystems = nixpkgs.lib.genAttrs systems; 10 | nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 11 | optionsDocFor = forAllSystems (system: 12 | import ./nix/module-options-doc.nix (nixpkgsFor."${system}") 13 | ); 14 | in { 15 | overlays.default = final: prev: { 16 | comin = final.callPackage ./nix/package.nix { }; 17 | }; 18 | 19 | packages = forAllSystems (system: { 20 | comin = nixpkgsFor."${system}".callPackage ./nix/package.nix { }; 21 | default = self.packages."${system}".comin; 22 | generate-module-options = optionsDocFor."${system}".optionsDocCommonMarkGenerator; 23 | }); 24 | checks = forAllSystems (system: { 25 | module-options-doc = optionsDocFor."${system}".checkOptionsDocCommonMark; 26 | # I don't understand why nix flake check does't build packages.default 27 | package = self.packages."${system}".comin; 28 | }); 29 | 30 | nixosModules.comin = nixpkgs.lib.modules.importApply ./nix/module.nix { inherit self; }; 31 | devShells.x86_64-linux.default = let 32 | pkgs = nixpkgs.legacyPackages.x86_64-linux; 33 | in pkgs.mkShell { 34 | buildInputs = [ 35 | pkgs.go pkgs.godef pkgs.gopls 36 | pkgs.golangci-lint 37 | ]; 38 | }; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nlewo/comin 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/ProtonMail/go-crypto v1.1.5 7 | github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df 8 | github.com/dustin/go-humanize v1.0.1 9 | github.com/go-co-op/gocron/v2 v2.11.0 10 | github.com/go-git/go-git/v5 v5.11.0 11 | github.com/google/uuid v1.6.0 12 | github.com/prometheus/client_golang v1.19.0 13 | github.com/sirupsen/logrus v1.9.3 14 | github.com/spf13/cobra v1.8.0 15 | github.com/stretchr/testify v1.9.0 16 | gopkg.in/yaml.v2 v2.4.0 17 | ) 18 | 19 | require ( 20 | dario.cat/mergo v1.0.0 // indirect 21 | github.com/Microsoft/go-winio v0.6.1 // indirect 22 | github.com/beorn7/perks v1.0.1 // indirect 23 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 24 | github.com/cloudflare/circl v1.3.7 // indirect 25 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/emirpasic/gods v1.18.1 // indirect 28 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 29 | github.com/go-git/go-billy/v5 v5.5.0 // indirect 30 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 31 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 32 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 33 | github.com/jonboulle/clockwork v0.4.0 // indirect 34 | github.com/kevinburke/ssh_config v1.2.0 // indirect 35 | github.com/pjbgf/sha1cd v0.3.0 // indirect 36 | github.com/pmezard/go-difflib v1.0.0 // indirect 37 | github.com/prometheus/client_model v0.5.0 // indirect 38 | github.com/prometheus/common v0.48.0 // indirect 39 | github.com/prometheus/procfs v0.12.0 // indirect 40 | github.com/robfig/cron/v3 v3.0.1 // indirect 41 | github.com/sergi/go-diff v1.3.1 // indirect 42 | github.com/skeema/knownhosts v1.2.1 // indirect 43 | github.com/spf13/pflag v1.0.5 // indirect 44 | github.com/xanzy/ssh-agent v0.3.3 // indirect 45 | golang.org/x/crypto v0.24.0 // indirect 46 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect 47 | golang.org/x/mod v0.18.0 // indirect 48 | golang.org/x/net v0.26.0 // indirect 49 | golang.org/x/sync v0.7.0 // indirect 50 | golang.org/x/sys v0.22.0 // indirect 51 | golang.org/x/tools v0.22.0 // indirect 52 | google.golang.org/protobuf v1.32.0 // indirect 53 | gopkg.in/warnings.v0 v0.1.2 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= 7 | github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= 13 | github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= 14 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 15 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 16 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 17 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 18 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 19 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 20 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 21 | github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 22 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 27 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 28 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= 29 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 30 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 31 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 32 | github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= 33 | github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 34 | github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE= 35 | github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= 36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 37 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 38 | github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= 39 | github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= 40 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 41 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 42 | github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= 43 | github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= 44 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 45 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 46 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 47 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 48 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 49 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 50 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 51 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 52 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 53 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 54 | github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= 55 | github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= 56 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 57 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 58 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 59 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 60 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 61 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 62 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 63 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 64 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 65 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 66 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 67 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 68 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 69 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 70 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 71 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 72 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 74 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 75 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 76 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 77 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 78 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 79 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 80 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 81 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 82 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 83 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 84 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 85 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 86 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 87 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 88 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 89 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 90 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 91 | github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= 92 | github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 93 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 94 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 95 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 96 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 97 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 98 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 99 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 100 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 101 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 102 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 103 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 104 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 105 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 106 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 107 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 108 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 109 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 110 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= 111 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 112 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 113 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 114 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 115 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 116 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 117 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 118 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 119 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 121 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 122 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 123 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 126 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 127 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 128 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 129 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 130 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 131 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 132 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 133 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 134 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 135 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 136 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 137 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 138 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 139 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 140 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 141 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 142 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 143 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 144 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 145 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 146 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 147 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 148 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 149 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 150 | -------------------------------------------------------------------------------- /internal/builder/builder.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/nlewo/comin/internal/repository" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type EvalFunc func(ctx context.Context, flakeUrl string, hostname string) (drvPath string, outPath string, machineId string, err error) 15 | type BuildFunc func(ctx context.Context, drvPath string) error 16 | 17 | type Builder struct { 18 | hostname string 19 | repositoryPath string 20 | repositoryDir string 21 | evalTimeout time.Duration 22 | buildTimeout time.Duration 23 | evalFunc EvalFunc 24 | buildFunc BuildFunc 25 | 26 | mu sync.Mutex 27 | IsEvaluating bool 28 | IsBuilding bool 29 | 30 | generation *Generation 31 | 32 | // EvaluationDone is used to be notified a evaluation is finished. Be careful since only a single goroutine can listen it. 33 | EvaluationDone chan Generation 34 | // BuildDone is used to be notified a build is finished. Be careful since only a single goroutine can listen it. 35 | BuildDone chan Generation 36 | 37 | evaluator Exec 38 | evaluatorWg *sync.WaitGroup 39 | 40 | buildator Exec 41 | buildatorWg *sync.WaitGroup 42 | } 43 | 44 | func New(repositoryPath, repositoryDir, hostname string, evalTimeout time.Duration, evalFunc EvalFunc, buildTimeout time.Duration, buildFunc BuildFunc) *Builder { 45 | logrus.Infof("builder: initialization with repositoryPath=%s, repositoryDir=%s, hostname=%s, evalTimeout=%fs, buildTimeout=%fs, )", 46 | repositoryPath, repositoryDir, hostname, evalTimeout.Seconds(), buildTimeout.Seconds()) 47 | return &Builder{ 48 | repositoryPath: repositoryPath, 49 | repositoryDir: repositoryDir, 50 | hostname: hostname, 51 | evalFunc: evalFunc, 52 | evalTimeout: evalTimeout, 53 | buildFunc: buildFunc, 54 | buildTimeout: buildTimeout, 55 | EvaluationDone: make(chan Generation, 1), 56 | BuildDone: make(chan Generation, 1), 57 | evaluatorWg: &sync.WaitGroup{}, 58 | buildatorWg: &sync.WaitGroup{}, 59 | } 60 | } 61 | 62 | func (b *Builder) GetGeneration() Generation { 63 | b.mu.Lock() 64 | defer b.mu.Unlock() 65 | return *b.generation 66 | } 67 | 68 | type State struct { 69 | Hostname string `json:"is_hostname"` 70 | IsBuilding bool `json:"is_building"` 71 | IsEvaluating bool `json:"is_evaluating"` 72 | Generation *Generation `json:"generation"` 73 | } 74 | 75 | func (b *Builder) State() State { 76 | b.mu.Lock() 77 | defer b.mu.Unlock() 78 | return State{ 79 | Hostname: b.hostname, 80 | IsBuilding: b.IsBuilding, 81 | IsEvaluating: b.IsEvaluating, 82 | Generation: b.generation, 83 | } 84 | } 85 | 86 | // Stop stops the evaluator and the builder is required and wait until 87 | // they have been actually stopped. 88 | func (b *Builder) Stop() { 89 | b.evaluator.Stop() 90 | b.buildator.Stop() 91 | 92 | b.evaluatorWg.Wait() 93 | b.buildatorWg.Wait() 94 | b.mu.Lock() 95 | defer b.mu.Unlock() 96 | b.IsEvaluating = false 97 | b.IsBuilding = false 98 | } 99 | 100 | type Evaluator struct { 101 | flakeUrl string 102 | hostname string 103 | 104 | evalFunc EvalFunc 105 | 106 | drvPath string 107 | outPath string 108 | machineId string 109 | } 110 | 111 | func (r *Evaluator) Run(ctx context.Context) (err error) { 112 | r.drvPath, r.outPath, r.machineId, err = r.evalFunc(ctx, r.flakeUrl, r.hostname) 113 | return err 114 | } 115 | 116 | type Buildator struct { 117 | drvPath string 118 | buildFunc BuildFunc 119 | } 120 | 121 | func (r *Buildator) Run(ctx context.Context) (err error) { 122 | return r.buildFunc(ctx, r.drvPath) 123 | } 124 | 125 | // Eval evaluates a generation. It cancels current any generation 126 | // evaluation or build. 127 | func (b *Builder) Eval(rs repository.RepositoryStatus) { 128 | ctx := context.TODO() 129 | // This is to prempt the builder since we don't need to allow 130 | // several evaluation in parallel 131 | b.Stop() 132 | b.mu.Lock() 133 | defer b.mu.Unlock() 134 | b.IsEvaluating = true 135 | g := Generation{ 136 | UUID: uuid.NewString(), 137 | FlakeUrl: fmt.Sprintf("git+file://%s?dir=%s&rev=%s", b.repositoryPath, b.repositoryDir, rs.SelectedCommitId), 138 | Hostname: b.hostname, 139 | SelectedRemoteName: rs.SelectedRemoteName, 140 | SelectedBranchName: rs.SelectedBranchName, 141 | SelectedCommitId: rs.SelectedCommitId, 142 | SelectedCommitMsg: rs.SelectedCommitMsg, 143 | SelectedBranchIsTesting: rs.SelectedBranchIsTesting, 144 | MainRemoteName: rs.MainBranchName, 145 | MainBranchName: rs.MainBranchName, 146 | MainCommitId: rs.MainCommitId, 147 | EvalStartedAt: time.Now().UTC(), 148 | EvalStatus: Evaluating, 149 | } 150 | b.generation = &g 151 | 152 | evaluator := &Evaluator{ 153 | hostname: g.Hostname, 154 | flakeUrl: g.FlakeUrl, 155 | evalFunc: b.evalFunc, 156 | } 157 | b.evaluator = NewExec(evaluator, b.evalTimeout) 158 | 159 | // This is to wait until the evaluator is stopped 160 | b.evaluatorWg.Add(1) 161 | b.evaluator.Start(ctx) 162 | 163 | go func() { 164 | defer b.evaluatorWg.Done() 165 | b.evaluator.Wait() 166 | b.mu.Lock() 167 | defer b.mu.Unlock() 168 | b.generation.EvalErr = b.evaluator.err 169 | if b.evaluator.err != nil { 170 | b.generation.EvalErrStr = b.evaluator.err.Error() 171 | b.generation.EvalStatus = EvalFailed 172 | } else { 173 | b.generation.EvalStatus = Evaluated 174 | } 175 | b.generation.EvalErr = b.evaluator.err 176 | b.generation.DrvPath = evaluator.drvPath 177 | b.generation.OutPath = evaluator.outPath 178 | b.generation.MachineId = evaluator.machineId 179 | b.generation.EvalEndedAt = time.Now().UTC() 180 | b.IsEvaluating = false 181 | select { 182 | case b.EvaluationDone <- *b.generation: 183 | default: 184 | } 185 | }() 186 | } 187 | 188 | // Build builds a generation which has been previously evaluated. 189 | func (b *Builder) Build() error { 190 | ctx := context.TODO() 191 | b.mu.Lock() 192 | defer b.mu.Unlock() 193 | 194 | if b.generation == nil || b.generation.EvalStatus != Evaluated { 195 | return fmt.Errorf("the generation is not evaluated") 196 | } 197 | if b.IsBuilding { 198 | return fmt.Errorf("the builder is already building") 199 | } 200 | if b.generation.BuildStatus == Built { 201 | return fmt.Errorf("the generation is already built") 202 | } 203 | b.generation.BuildStartedAt = time.Now().UTC() 204 | b.generation.BuildStatus = Building 205 | b.IsBuilding = true 206 | 207 | buildator := &Buildator{ 208 | drvPath: b.generation.DrvPath, 209 | buildFunc: b.buildFunc, 210 | } 211 | b.buildator = NewExec(buildator, b.buildTimeout) 212 | 213 | // This is to wait until the evaluator is stopped 214 | b.buildatorWg.Add(1) 215 | b.buildator.Start(ctx) 216 | 217 | go func() { 218 | defer b.buildatorWg.Done() 219 | b.buildator.Wait() 220 | b.mu.Lock() 221 | defer b.mu.Unlock() 222 | b.generation.BuildEndedAt = time.Now().UTC() 223 | b.generation.BuildErr = b.buildator.err 224 | if b.buildator.err == nil { 225 | b.generation.BuildStatus = Built 226 | } else { 227 | b.generation.BuildStatus = BuildFailed 228 | b.generation.BuildErrStr = b.buildator.err.Error() 229 | } 230 | b.IsBuilding = false 231 | select { 232 | case b.BuildDone <- *b.generation: 233 | default: 234 | } 235 | }() 236 | return nil 237 | } 238 | -------------------------------------------------------------------------------- /internal/builder/builder_test.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | "time" 8 | 9 | "github.com/nlewo/comin/internal/repository" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "net/http" 13 | _ "net/http/pprof" 14 | ) 15 | 16 | var mkNixEvalMock = func(evalDone chan struct{}) EvalFunc { 17 | return func(ctx context.Context, repositoryPath string, hostname string) (string, string, string, error) { 18 | select { 19 | case <-ctx.Done(): 20 | return "", "", "", ctx.Err() 21 | case <-evalDone: 22 | return "drv-path", "out-path", "", nil 23 | } 24 | } 25 | } 26 | 27 | var mkNixBuildMock = func(buildDone chan struct{}) BuildFunc { 28 | return func(ctx context.Context, drvPath string) error { 29 | select { 30 | case <-ctx.Done(): 31 | return ctx.Err() 32 | case <-buildDone: 33 | return nil 34 | } 35 | } 36 | } 37 | 38 | var nixBuildMockNil = func(ctx context.Context, drvPath string) error { return nil } 39 | 40 | func TestBuilderBuild(t *testing.T) { 41 | go func() { 42 | log.Println(http.ListenAndServe("localhost:6060", nil)) 43 | }() 44 | evalDone := make(chan struct{}) 45 | buildDone := make(chan struct{}) 46 | 47 | b := New("", "", "my-machine", 2*time.Second, mkNixEvalMock(evalDone), 2*time.Second, mkNixBuildMock(buildDone)) 48 | 49 | assert.ErrorContains(t, b.Build(), "the generation is not evaluated") 50 | // Run the evaluator 51 | b.Eval(repository.RepositoryStatus{}) 52 | close(evalDone) 53 | 54 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 55 | assert.False(c, b.IsEvaluating) 56 | }, 2*time.Second, 100*time.Millisecond) 57 | 58 | _ = b.Build() 59 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 60 | assert.True(c, b.IsBuilding) 61 | }, 2*time.Second, 100*time.Millisecond) 62 | err := b.Build() 63 | assert.ErrorContains(t, err, "the builder is already building") 64 | 65 | // Stop the evaluator and builder 66 | b.Stop() 67 | 68 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 69 | assert.False(c, b.IsBuilding) 70 | g := b.GetGeneration() 71 | assert.ErrorContains(c, g.BuildErr, "context canceled") 72 | }, 2*time.Second, 100*time.Millisecond) 73 | 74 | // The builder timeouts 75 | err = b.Build() 76 | assert.Nil(t, err) 77 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 78 | g := b.GetGeneration() 79 | assert.ErrorContains(c, g.BuildErr, "context deadline exceeded") 80 | }, 3*time.Second, 100*time.Millisecond) 81 | 82 | // The builder succeeds 83 | err = b.Build() 84 | assert.Nil(t, err) 85 | buildDone <- struct{}{} 86 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 87 | assert.False(c, b.IsBuilding) 88 | }, 3*time.Second, 100*time.Millisecond) 89 | 90 | // The generation is already built 91 | err = b.Build() 92 | assert.ErrorContains(t, err, "the generation is already built") 93 | } 94 | 95 | func TestEval(t *testing.T) { 96 | evalDone := make(chan struct{}) 97 | b := New("", "", "", 5*time.Second, mkNixEvalMock(evalDone), 5*time.Second, nixBuildMockNil) 98 | b.Eval(repository.RepositoryStatus{}) 99 | assert.True(t, b.IsEvaluating) 100 | 101 | evalDone <- struct{}{} 102 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 103 | assert.False(c, b.IsEvaluating) 104 | g := b.GetGeneration() 105 | assert.Equal(c, Evaluated, g.EvalStatus) 106 | assert.Equal(c, "drv-path", g.DrvPath) 107 | assert.Equal(c, "out-path", g.OutPath) 108 | }, 2*time.Second, 100*time.Millisecond) 109 | } 110 | 111 | func TestBuilderPreemption(t *testing.T) { 112 | evalDone := make(chan struct{}) 113 | b := New("", "", "", 5*time.Second, mkNixEvalMock(evalDone), 5*time.Second, nixBuildMockNil) 114 | b.Eval(repository.RepositoryStatus{SelectedCommitId: "commit-1"}) 115 | assert.True(t, b.IsEvaluating) 116 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 117 | g := b.GetGeneration() 118 | assert.Equal(c, "commit-1", g.SelectedCommitId) 119 | }, 2*time.Second, 100*time.Millisecond) 120 | 121 | b.Eval(repository.RepositoryStatus{SelectedCommitId: "commit-2"}) 122 | assert.True(t, b.IsEvaluating) 123 | 124 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 125 | g := b.GetGeneration() 126 | assert.Equal(c, "commit-2", g.SelectedCommitId) 127 | }, 2*time.Second, 100*time.Millisecond) 128 | } 129 | 130 | func TestBuilderStop(t *testing.T) { 131 | evalDone := make(chan struct{}) 132 | b := New("", "", "", 5*time.Second, mkNixEvalMock(evalDone), 5*time.Second, nixBuildMockNil) 133 | b.Eval(repository.RepositoryStatus{}) 134 | assert.True(t, b.IsEvaluating) 135 | b.Stop() 136 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 137 | g := b.GetGeneration() 138 | assert.ErrorContains(c, g.EvalErr, "context canceled") 139 | }, 2*time.Second, 100*time.Millisecond) 140 | } 141 | 142 | func TestBuilderTimeout(t *testing.T) { 143 | evalDone := make(chan struct{}) 144 | b := New("", "", "", 1*time.Second, mkNixEvalMock(evalDone), 5*time.Second, nixBuildMockNil) 145 | b.Eval(repository.RepositoryStatus{}) 146 | assert.True(t, b.IsEvaluating) 147 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 148 | g := b.GetGeneration() 149 | assert.ErrorContains(c, g.EvalErr, "context deadline exceeded") 150 | }, 3*time.Second, 100*time.Millisecond, "builder timeout didn't work") 151 | } 152 | -------------------------------------------------------------------------------- /internal/builder/exec.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type Runnable interface { 10 | Run(c context.Context) error 11 | } 12 | 13 | type Exec struct { 14 | timeout time.Duration 15 | runnable Runnable 16 | Started bool 17 | Finished bool 18 | Stopped bool 19 | Timeouted bool 20 | done chan struct{} 21 | err error 22 | cancelFunc context.CancelFunc 23 | mu sync.Mutex 24 | } 25 | 26 | func NewExec(r Runnable, timeout time.Duration) Exec { 27 | return Exec{ 28 | runnable: r, 29 | mu: sync.Mutex{}, 30 | done: make(chan struct{}), 31 | timeout: timeout, 32 | } 33 | } 34 | 35 | func (e *Exec) Start(ctx context.Context) { 36 | e.mu.Lock() 37 | defer e.mu.Unlock() 38 | e.Started = true 39 | ctx, e.cancelFunc = context.WithCancel(ctx) 40 | ctx, cancel := context.WithTimeout(ctx, e.timeout) 41 | 42 | go func() { 43 | defer e.cancelFunc() 44 | defer cancel() 45 | err := e.runnable.Run(ctx) 46 | e.mu.Lock() 47 | defer e.mu.Unlock() 48 | if ctx.Err() != nil { 49 | e.err = ctx.Err() 50 | } else { 51 | e.err = err 52 | } 53 | e.Started = false 54 | e.Finished = true 55 | select { 56 | case e.done <- struct{}{}: 57 | default: 58 | } 59 | }() 60 | } 61 | 62 | func (e *Exec) Wait() { 63 | if e.Started { 64 | <-e.done 65 | } 66 | } 67 | 68 | func (e *Exec) Stop() { 69 | e.mu.Lock() 70 | defer e.mu.Unlock() 71 | if e.Started { 72 | e.Stopped = true 73 | e.cancelFunc() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/builder/exec_test.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "testing" 8 | "time" 9 | 10 | _ "net/http/pprof" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type RunnableDummy struct { 16 | result int 17 | } 18 | 19 | func (r *RunnableDummy) Run(ctx context.Context) error { 20 | r.result = 1 21 | return nil 22 | } 23 | 24 | func TestNewExec(t *testing.T) { 25 | r := &RunnableDummy{} 26 | e := NewExec(r, time.Second) 27 | assert.Equal(t, 0, r.result) 28 | e.Start(context.TODO()) 29 | e.Wait() 30 | assert.Equal(t, 1, r.result) 31 | assert.True(t, e.Finished) 32 | assert.Nil(t, e.err) 33 | } 34 | 35 | type RunnableContext struct{} 36 | 37 | func (r *RunnableContext) Run(ctx context.Context) error { 38 | cmd := exec.CommandContext(ctx, "sleep", "3") 39 | err := cmd.Run() 40 | return err 41 | } 42 | func TestExecTimeout(t *testing.T) { 43 | r := &RunnableContext{} 44 | e := NewExec(r, time.Second) 45 | e.Start(context.TODO()) 46 | e.Wait() 47 | assert.Equal(t, context.DeadlineExceeded, e.err) 48 | } 49 | 50 | func TestExecStop(t *testing.T) { 51 | r := &RunnableContext{} 52 | e := NewExec(r, 5*time.Second) 53 | e.Start(context.TODO()) 54 | time.Sleep(500 * time.Millisecond) 55 | e.Stop() 56 | e.Wait() 57 | assert.True(t, e.Stopped) 58 | assert.Equal(t, context.Canceled, e.err) 59 | } 60 | 61 | type RunnableError struct{} 62 | 63 | func (r *RunnableError) Run(ctx context.Context) error { 64 | return fmt.Errorf("An error occured") 65 | } 66 | func TestExecError(t *testing.T) { 67 | r := &RunnableError{} 68 | e := NewExec(r, 5*time.Second) 69 | e.Start(context.TODO()) 70 | e.Wait() 71 | assert.True(t, e.Finished) 72 | assert.ErrorContains(t, e.err, "An error occured") 73 | } 74 | -------------------------------------------------------------------------------- /internal/builder/generation.go: -------------------------------------------------------------------------------- 1 | package builder 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/dustin/go-humanize" 9 | ) 10 | 11 | type EvalStatus int64 12 | 13 | const ( 14 | EvalInit EvalStatus = iota 15 | Evaluating 16 | Evaluated 17 | EvalFailed 18 | ) 19 | 20 | func (s EvalStatus) String() string { 21 | switch s { 22 | case EvalInit: 23 | return "initialized" 24 | case Evaluating: 25 | return "evaluating" 26 | case Evaluated: 27 | return "evaluated" 28 | case EvalFailed: 29 | return "failed" 30 | } 31 | return "unknown" 32 | } 33 | 34 | type BuildStatus int64 35 | 36 | const ( 37 | BuildInit BuildStatus = iota 38 | Building 39 | Built 40 | BuildFailed 41 | ) 42 | 43 | func (s BuildStatus) String() string { 44 | switch s { 45 | case BuildInit: 46 | return "initialized" 47 | case Building: 48 | return "building" 49 | case Built: 50 | return "built" 51 | case BuildFailed: 52 | return "failed" 53 | } 54 | return "unknown" 55 | } 56 | 57 | // We consider each created genration is legit to be deployed: hard 58 | // reset is ensured at RepositoryStatus creation. 59 | type Generation struct { 60 | UUID string `json:"uuid"` 61 | FlakeUrl string `json:"flake-url"` 62 | Hostname string `json:"hostname"` 63 | 64 | SelectedRemoteUrl string `json:"remote-url"` 65 | SelectedRemoteName string `json:"remote-name"` 66 | SelectedBranchName string `json:"branch-name"` 67 | SelectedCommitId string `json:"commit-id"` 68 | SelectedCommitMsg string `json:"commit-msg"` 69 | SelectedBranchIsTesting bool `json:"branch-is-testing"` 70 | 71 | MainCommitId string `json:"main-commit-id"` 72 | MainRemoteName string `json:"main-remote-name"` 73 | MainBranchName string `json:"main-branch-name"` 74 | 75 | EvalStatus EvalStatus `json:"eval-status"` 76 | EvalStartedAt time.Time `json:"eval-started-at"` 77 | EvalEndedAt time.Time `json:"eval-ended-at"` 78 | EvalErr error `json:"-"` 79 | EvalErrStr string `json:"eval-err"` 80 | OutPath string `json:"outpath"` 81 | DrvPath string `json:"drvpath"` 82 | 83 | MachineId string `json:"machine-id"` 84 | 85 | BuildStatus BuildStatus `json:"build-status"` 86 | BuildStartedAt time.Time `json:"build-started-at"` 87 | BuildEndedAt time.Time `json:"build-ended-at"` 88 | BuildErr error `json:"-"` 89 | BuildErrStr string `json:"build-err"` 90 | } 91 | 92 | func GenerationShow(g Generation) { 93 | padding := " " 94 | fmt.Printf("%sGeneration UUID %s\n", padding, g.UUID) 95 | fmt.Printf("%sCommit ID %s from %s/%s\n", padding, g.SelectedCommitId, g.SelectedRemoteName, g.SelectedBranchName) 96 | fmt.Printf("%sCommit message: %s\n", padding, strings.Trim(g.SelectedCommitMsg, "\n")) 97 | 98 | if g.EvalStatus == EvalInit { 99 | fmt.Printf("%sNo evaluation started\n", padding) 100 | return 101 | } 102 | if g.EvalStatus == Evaluating { 103 | fmt.Printf("%sEvaluation started %s\n", padding, humanize.Time(g.EvalStartedAt)) 104 | return 105 | } 106 | switch g.EvalStatus { 107 | case Evaluated: 108 | fmt.Printf("%sEvaluation succedded %s\n", padding, humanize.Time(g.EvalEndedAt)) 109 | fmt.Printf("%s DrvPath: %s\n", padding, g.DrvPath) 110 | case EvalFailed: 111 | fmt.Printf("%sEvaluation failed %s\n", padding, humanize.Time(g.EvalEndedAt)) 112 | } 113 | if g.BuildStatus == BuildInit { 114 | fmt.Printf("%sNo build started\n", padding) 115 | return 116 | } 117 | if g.BuildStatus == Building { 118 | fmt.Printf("%sBuild started %s\n", padding, humanize.Time(g.BuildStartedAt)) 119 | return 120 | } 121 | switch g.BuildStatus { 122 | case Built: 123 | fmt.Printf("%sBuilt %s\n", padding, humanize.Time(g.BuildEndedAt)) 124 | fmt.Printf("%s Outpath: %s\n", padding, g.OutPath) 125 | case BuildFailed: 126 | fmt.Printf("%sBuild failed %s\n", padding, humanize.Time(g.BuildEndedAt)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/nlewo/comin/internal/types" 9 | "github.com/sirupsen/logrus" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | func Read(path string) (config types.Configuration, err error) { 14 | file, err := os.Open(path) 15 | if err != nil { 16 | return 17 | } 18 | defer file.Close() // nolint 19 | 20 | d := yaml.NewDecoder(file) 21 | if err := d.Decode(&config); err != nil { 22 | return config, err 23 | } 24 | for i, remote := range config.Remotes { 25 | if remote.Auth.AccessTokenPath != "" { 26 | content, err := os.ReadFile(remote.Auth.AccessTokenPath) 27 | if err != nil { 28 | return config, err 29 | } 30 | config.Remotes[i].Auth.AccessToken = strings.TrimSpace(string(content)) 31 | } 32 | if remote.Timeout == 0 { 33 | config.Remotes[i].Timeout = 300 34 | } 35 | } 36 | 37 | if config.ApiServer.ListenAddress == "" { 38 | config.ApiServer.ListenAddress = "127.0.0.1" 39 | } 40 | if config.ApiServer.Port == 0 { 41 | config.ApiServer.Port = 4242 42 | } 43 | if config.Exporter.ListenAddress == "" { 44 | config.Exporter.ListenAddress = "0.0.0.0" 45 | } 46 | if config.Exporter.Port == 0 { 47 | config.Exporter.Port = 4243 48 | } 49 | if config.StateFilepath == "" { 50 | config.StateFilepath = filepath.Join(config.StateDir, "state.json") 51 | } 52 | if config.FlakeSubdirectory == "" { 53 | config.FlakeSubdirectory = "." 54 | } 55 | logrus.Debugf("Config is '%#v'", config) 56 | return 57 | } 58 | 59 | func MkGitConfig(config types.Configuration) types.GitConfig { 60 | return types.GitConfig{ 61 | Path: filepath.Join(config.StateDir, "repository"), 62 | Dir: config.FlakeSubdirectory, 63 | Remotes: config.Remotes, 64 | GpgPublicKeyPaths: config.GpgPublicKeyPaths, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nlewo/comin/internal/types" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestConfig(t *testing.T) { 11 | configPath := "./configuration.yaml" 12 | expected := types.Configuration{ 13 | Hostname: "machine", 14 | StateDir: "/var/lib/comin", 15 | StateFilepath: "/var/lib/comin/state.json", 16 | PostDeploymentCommand: "/some/path", 17 | FlakeSubdirectory: ".", 18 | Remotes: []types.Remote{ 19 | { 20 | Name: "origin", 21 | URL: "https://framagit.org/owner/infra", 22 | Auth: types.Auth{ 23 | AccessToken: "my-secret", 24 | AccessTokenPath: "./secret", 25 | }, 26 | Timeout: 300, 27 | }, 28 | { 29 | Name: "local", 30 | URL: "/home/owner/git/infra", 31 | Auth: types.Auth{ 32 | AccessToken: "", 33 | AccessTokenPath: "", 34 | }, 35 | Timeout: 300, 36 | }, 37 | }, 38 | ApiServer: types.HttpServer{ 39 | ListenAddress: "127.0.0.1", 40 | Port: 4242, 41 | }, 42 | Exporter: types.HttpServer{ 43 | ListenAddress: "0.0.0.0", 44 | Port: 4243, 45 | }, 46 | } 47 | config, err := Read(configPath) 48 | assert.Nil(t, err) 49 | assert.Equal(t, expected, config) 50 | } 51 | -------------------------------------------------------------------------------- /internal/config/configuration.yaml: -------------------------------------------------------------------------------- 1 | hostname: machine 2 | state_dir: /var/lib/comin 3 | post_deployment_command: "/some/path" 4 | remotes: 5 | - name: origin 6 | type: https 7 | url: https://framagit.org/owner/infra 8 | auth: 9 | access_token_path: ./secret 10 | - name: local 11 | type: local 12 | url: /home/owner/git/infra 13 | branches: 14 | main: 15 | name: main 16 | protected: true 17 | testing: 18 | name: testing-machine 19 | protected: false 20 | poller: 21 | period: 10 22 | -------------------------------------------------------------------------------- /internal/config/secret: -------------------------------------------------------------------------------- 1 | my-secret -------------------------------------------------------------------------------- /internal/deployer/deployer.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/dustin/go-humanize" 11 | "github.com/google/uuid" 12 | "github.com/nlewo/comin/internal/builder" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type Status int64 17 | 18 | const ( 19 | Init Status = iota 20 | Running 21 | Done 22 | Failed 23 | ) 24 | 25 | func StatusToString(status Status) string { 26 | switch status { 27 | case Init: 28 | return "init" 29 | case Running: 30 | return "running" 31 | case Done: 32 | return "done" 33 | case Failed: 34 | return "failed" 35 | } 36 | return "" 37 | } 38 | 39 | type Deployment struct { 40 | UUID string `json:"uuid"` 41 | Generation builder.Generation `json:"generation"` 42 | StartedAt time.Time `json:"started_at"` 43 | EndedAt time.Time `json:"ended_at"` 44 | // It is ignored in the JSON marshaling 45 | Err error `json:"-"` 46 | ErrorMsg string `json:"error_msg"` 47 | RestartComin bool `json:"restart_comin"` 48 | ProfilePath string `json:"profile_path"` 49 | Status Status `json:"status"` 50 | Operation string `json:"operation"` 51 | } 52 | 53 | type DeployFunc func(context.Context, string, string) (bool, string, error) 54 | 55 | type Deployer struct { 56 | GenerationCh chan builder.Generation 57 | deployerFunc DeployFunc 58 | DeploymentDoneCh chan Deployment 59 | mu sync.Mutex 60 | Deployment *Deployment 61 | previousDeployment *Deployment 62 | IsDeploying bool 63 | // The next generation to deploy. nil when there is no new generation to deploy 64 | GenerationToDeploy *builder.Generation 65 | generationAvailableCh chan struct{} 66 | postDeploymentCommand string 67 | } 68 | 69 | func (d Deployment) IsTesting() bool { 70 | return d.Operation == "test" 71 | } 72 | 73 | type State struct { 74 | IsDeploying bool `json:"is_deploying"` 75 | GenerationToDeploy *builder.Generation `json:"generation_to_deploy"` 76 | Deployment *Deployment `json:"deployment"` 77 | PreviousDeployment *Deployment `json:"previous_deployment"` 78 | } 79 | 80 | func (d *Deployer) State() State { 81 | return State{ 82 | IsDeploying: d.IsDeploying, 83 | GenerationToDeploy: d.GenerationToDeploy, 84 | Deployment: d.Deployment, 85 | PreviousDeployment: d.previousDeployment, 86 | } 87 | } 88 | 89 | func showDeployment(padding string, d Deployment) { 90 | switch d.Status { 91 | case Running: 92 | fmt.Printf("%sDeployment is running since %s\n", padding, humanize.Time(d.StartedAt)) 93 | fmt.Printf("%sOperation %s\n", padding, d.Operation) 94 | case Done: 95 | fmt.Printf("%sDeployment succeeded %s\n", padding, humanize.Time(d.EndedAt)) 96 | fmt.Printf("%sOperation %s\n", padding, d.Operation) 97 | fmt.Printf("%sProfilePath %s\n", padding, d.ProfilePath) 98 | case Failed: 99 | fmt.Printf("%sDeployment failed %s\n", padding, humanize.Time(d.EndedAt)) 100 | fmt.Printf("%sOperation %s\n", padding, d.Operation) 101 | fmt.Printf("%sProfilePath %s\n", padding, d.ProfilePath) 102 | } 103 | fmt.Printf("%sGeneration %s\n", padding, d.Generation.UUID) 104 | fmt.Printf("%sCommit ID %s from %s/%s\n", padding, d.Generation.SelectedCommitId, d.Generation.SelectedRemoteName, d.Generation.SelectedBranchName) 105 | fmt.Printf("%sCommit message %s\n", padding, strings.Trim(d.Generation.SelectedCommitMsg, "\n")) 106 | fmt.Printf("%sOutpath %s\n", padding, d.Generation.OutPath) 107 | } 108 | 109 | func (s State) Show(padding string) { 110 | fmt.Printf(" Deployer\n") 111 | if s.Deployment == nil { 112 | showDeployment(padding, *s.PreviousDeployment) 113 | return 114 | } 115 | showDeployment(padding, *s.Deployment) 116 | } 117 | 118 | func New(deployFunc DeployFunc, previousDeployment *Deployment, postDeploymentCommand string) *Deployer { 119 | return &Deployer{ 120 | DeploymentDoneCh: make(chan Deployment, 1), 121 | deployerFunc: deployFunc, 122 | generationAvailableCh: make(chan struct{}, 1), 123 | previousDeployment: previousDeployment, 124 | Deployment: previousDeployment, 125 | postDeploymentCommand: postDeploymentCommand, 126 | } 127 | } 128 | 129 | // Submit submits a generation to be deployed. If a deployment is 130 | // running, this generation will be deployed once the current 131 | // deployment is finished. If this generation is the same than the one 132 | // of the last deployment, this generation is skipped. 133 | func (d *Deployer) Submit(generation builder.Generation) { 134 | logrus.Infof("deployer: submiting generation %s", generation.UUID) 135 | d.mu.Lock() 136 | if d.previousDeployment == nil || generation.SelectedCommitId != d.previousDeployment.Generation.SelectedCommitId || generation.SelectedBranchIsTesting != d.previousDeployment.Generation.SelectedBranchIsTesting { 137 | d.GenerationToDeploy = &generation 138 | select { 139 | case d.generationAvailableCh <- struct{}{}: 140 | default: 141 | } 142 | } else { 143 | logrus.Infof("deployer: skipping deployment of the generation %s because it is the same than the last deployment", generation.UUID) 144 | } 145 | d.mu.Unlock() 146 | } 147 | 148 | func (d *Deployer) Run() { 149 | go func() { 150 | for { 151 | <-d.generationAvailableCh 152 | d.mu.Lock() 153 | g := d.GenerationToDeploy 154 | d.GenerationToDeploy = nil 155 | d.mu.Unlock() 156 | logrus.Infof("deployer: deploying generation %s", g.UUID) 157 | 158 | operation := "switch" 159 | if g.SelectedBranchIsTesting { 160 | operation = "test" 161 | } 162 | dpl := Deployment{ 163 | UUID: uuid.NewString(), 164 | Generation: *g, 165 | Operation: operation, 166 | StartedAt: time.Now().UTC(), 167 | Status: Running, 168 | } 169 | d.mu.Lock() 170 | d.previousDeployment = d.Deployment 171 | d.Deployment = &dpl 172 | d.IsDeploying = true 173 | d.mu.Unlock() 174 | 175 | ctx := context.TODO() 176 | cominNeedRestart, profilePath, err := d.deployerFunc( 177 | ctx, 178 | g.OutPath, 179 | operation, 180 | ) 181 | 182 | d.mu.Lock() 183 | d.IsDeploying = false 184 | d.Deployment.EndedAt = time.Now().UTC() 185 | d.Deployment.Err = err 186 | if err != nil { 187 | d.Deployment.ErrorMsg = err.Error() 188 | d.Deployment.Status = Failed 189 | } else { 190 | d.Deployment.Status = Done 191 | } 192 | d.Deployment.RestartComin = cominNeedRestart 193 | d.Deployment.ProfilePath = profilePath 194 | d.DeploymentDoneCh <- *d.Deployment 195 | d.mu.Unlock() 196 | 197 | cmd := d.postDeploymentCommand 198 | if cmd != "" { 199 | _, err = runPostDeploymentCommand(cmd, d.Deployment) 200 | if err != nil { 201 | logrus.Errorf("deployer: deploying generation %s, post deployment command [%s] failed %v", g.UUID, cmd, err) 202 | } 203 | } 204 | } 205 | }() 206 | } 207 | -------------------------------------------------------------------------------- /internal/deployer/deployer_test.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/nlewo/comin/internal/builder" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDeployerBasic(t *testing.T) { 13 | deployDone := make(chan struct{}) 14 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 15 | <-deployDone 16 | return false, "profile-path", nil 17 | } 18 | 19 | d := New(deployFunc, nil, "") 20 | d.Run() 21 | assert.False(t, d.IsDeploying) 22 | 23 | g := builder.Generation{SelectedCommitId: "commit-1"} 24 | d.Submit(g) 25 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 26 | assert.True(c, d.IsDeploying) 27 | }, 5*time.Second, 100*time.Millisecond) 28 | 29 | deployDone <- struct{}{} 30 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 31 | assert.False(c, d.IsDeploying) 32 | assert.Equal(c, "profile-path", d.Deployment.ProfilePath) 33 | }, 5*time.Second, 100*time.Millisecond) 34 | 35 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 36 | dpl := <-d.DeploymentDoneCh 37 | assert.Equal(c, "profile-path", dpl.ProfilePath) 38 | assert.Equal(c, "commit-1", dpl.Generation.SelectedCommitId) 39 | }, 5*time.Second, 100*time.Millisecond) 40 | } 41 | 42 | func TestDeployerSubmit(t *testing.T) { 43 | deployDone := make(chan struct{}) 44 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 45 | <-deployDone 46 | return false, "profile-path", nil 47 | } 48 | 49 | d := New(deployFunc, nil, "") 50 | d.Run() 51 | assert.False(t, d.IsDeploying) 52 | 53 | d.Submit(builder.Generation{SelectedCommitId: "commit-1"}) 54 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 55 | assert.True(c, d.IsDeploying) 56 | assert.Nil(c, d.GenerationToDeploy) 57 | }, 5*time.Second, 100*time.Millisecond) 58 | 59 | d.Submit(builder.Generation{SelectedCommitId: "commit-2"}) 60 | d.Submit(builder.Generation{SelectedCommitId: "commit-3"}) 61 | assert.NotNil(t, d.GenerationToDeploy) 62 | 63 | // To simulate the end of 2 deployments (commit-1 and commit-3) 64 | deployDone <- struct{}{} 65 | deployDone <- struct{}{} 66 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 67 | assert.False(c, d.IsDeploying) 68 | assert.Equal(c, "profile-path", d.Deployment.ProfilePath) 69 | assert.Nil(t, d.GenerationToDeploy) 70 | }, 5*time.Second, 100*time.Millisecond) 71 | 72 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 73 | dpl := <-d.DeploymentDoneCh 74 | assert.Equal(c, "profile-path", dpl.ProfilePath) 75 | assert.Equal(c, "commit-1", dpl.Generation.SelectedCommitId) 76 | }, 5*time.Second, 100*time.Millisecond) 77 | } 78 | -------------------------------------------------------------------------------- /internal/deployer/post_deployment_command.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func envGitSha(d *Deployment) string { 13 | return d.Generation.SelectedCommitId 14 | } 15 | 16 | func envGitRef(d *Deployment) string { 17 | return fmt.Sprintf("%s/%s", d.Generation.SelectedRemoteName, d.Generation.SelectedBranchName) 18 | } 19 | 20 | func envGitMessage(d *Deployment) string { 21 | return strings.Trim(d.Generation.SelectedCommitMsg, "\n") 22 | } 23 | 24 | func envCominGeneration(d *Deployment) string { 25 | return d.Generation.UUID 26 | } 27 | 28 | func envCominHostname(d *Deployment) string { 29 | return d.Generation.Hostname 30 | } 31 | 32 | func envCominStatus(d *Deployment) string { 33 | return StatusToString(d.Status) 34 | } 35 | 36 | func envCominErrorMessage(d *Deployment) string { 37 | return d.ErrorMsg 38 | } 39 | 40 | func envCominFlakeUrl(d *Deployment) string { 41 | return d.Generation.FlakeUrl 42 | } 43 | 44 | func runPostDeploymentCommand(command string, d *Deployment) (string, error) { 45 | 46 | cmd := exec.Command(command) 47 | 48 | cmd.Env = append(os.Environ(), 49 | "COMIN_GIT_SHA="+envGitSha(d), 50 | "COMIN_GIT_REF="+envGitRef(d), 51 | "COMIN_GIT_MSG="+envGitMessage(d), 52 | "COMIN_HOSTNAME="+envCominHostname(d), 53 | "COMIN_FLAKE_URL="+envCominFlakeUrl(d), 54 | "COMIN_GENERATION="+envCominGeneration(d), 55 | "COMIN_STATUS="+envCominStatus(d), 56 | "COMIN_ERROR_MSG="+envCominErrorMessage(d), 57 | ) 58 | 59 | output, err := cmd.CombinedOutput() 60 | outputString := string(output) 61 | if err != nil { 62 | return outputString, err 63 | } 64 | 65 | logrus.Debugf("cmd:[%s] output:[%s]", command, outputString) 66 | 67 | return outputString, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/deployer/post_deployment_command_test.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBasic(t *testing.T) { 11 | 12 | startedAt := time.Now() 13 | endedAt := startedAt.Add(10 * time.Second) 14 | deployment := Deployment{ 15 | UUID: "uuid", 16 | // Generation builder.Generation 17 | StartedAt: startedAt, 18 | EndedAt: endedAt, 19 | Err: nil, 20 | ErrorMsg: "", 21 | RestartComin: false, 22 | ProfilePath: "", 23 | Status: Done, 24 | Operation: "", 25 | } 26 | 27 | out, err := runPostDeploymentCommand("env", &deployment) 28 | assert.NoError(t, err) 29 | assert.Contains(t, out, "COMIN_GIT_SHA=") 30 | } 31 | -------------------------------------------------------------------------------- /internal/executor/executor.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Executor interface { 8 | Eval(ctx context.Context, flakeUrl, hostname string) (drvPath string, outPath string, machineId string, err error) 9 | Build(ctx context.Context, drvPath string) (err error) 10 | Deploy(ctx context.Context, outPath, operation string) (needToRestartComin bool, profilePath string, err error) 11 | } 12 | 13 | func New() (e Executor, err error) { 14 | e, err = NewNixExecutor() 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /internal/executor/nix.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "os" 8 | ) 9 | 10 | type NixLocal struct{} 11 | 12 | func NewNixExecutor() (*NixLocal, error) { 13 | return &NixLocal{}, nil 14 | } 15 | 16 | func (n *NixLocal) ShowDerivation(ctx context.Context, flakeUrl, hostname string) (drvPath string, outPath string, err error) { 17 | return showDerivation(ctx, flakeUrl, hostname) 18 | } 19 | 20 | func (n *NixLocal) Eval(ctx context.Context, flakeUrl, hostname string) (drvPath string, outPath string, machineId string, err error) { 21 | drvPath, outPath, err = showDerivation(ctx, flakeUrl, hostname) 22 | if err != nil { 23 | return 24 | } 25 | machineId, err = getExpectedMachineId(flakeUrl, hostname) 26 | return 27 | } 28 | 29 | func (n *NixLocal) Build(ctx context.Context, drvPath string) (err error) { 30 | return build(ctx, drvPath) 31 | } 32 | 33 | func (n *NixLocal) Deploy(ctx context.Context, outPath, operation string) (needToRestartComin bool, profilePath string, err error) { 34 | return deploy(ctx, outPath, operation) 35 | } 36 | 37 | type Path struct { 38 | Path string `json:"path"` 39 | } 40 | 41 | type Output struct { 42 | Out Path `json:"out"` 43 | } 44 | 45 | type Derivation struct { 46 | Outputs Output `json:"outputs"` 47 | } 48 | 49 | type Show struct { 50 | NixosConfigurations map[string]struct{} `json:"nixosConfigurations"` 51 | } 52 | 53 | func (n *NixLocal) List(flakeUrl string) (hosts []string, err error) { 54 | args := []string{ 55 | "flake", 56 | "show", 57 | "--json", 58 | flakeUrl, 59 | } 60 | var stdout bytes.Buffer 61 | err = runNixCommand(args, &stdout, os.Stderr) 62 | if err != nil { 63 | return 64 | } 65 | 66 | var output Show 67 | err = json.Unmarshal(stdout.Bytes(), &output) 68 | if err != nil { 69 | return 70 | } 71 | hosts = make([]string, 0, len(output.NixosConfigurations)) 72 | for key := range output.NixosConfigurations { 73 | hosts = append(hosts, key) 74 | } 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /internal/executor/utils.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/nlewo/comin/internal/profile" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // GetExpectedMachineId evals 20 | // nixosConfigurations.MACHINE.config.services.comin.machineId and 21 | // returns (machine-id, nil) is comin.machineId is set, ("", nil) otherwise. 22 | func getExpectedMachineId(path, hostname string) (machineId string, err error) { 23 | expr := fmt.Sprintf("%s#nixosConfigurations.%s.config.services.comin.machineId", path, hostname) 24 | args := []string{ 25 | "eval", 26 | expr, 27 | "--json", 28 | } 29 | var stdout bytes.Buffer 30 | err = runNixCommand(args, &stdout, os.Stderr) 31 | if err != nil { 32 | return 33 | } 34 | var machineIdPtr *string 35 | err = json.Unmarshal(stdout.Bytes(), &machineIdPtr) 36 | if err != nil { 37 | return 38 | } 39 | if machineIdPtr != nil { 40 | logrus.Debugf("nix: getting comin.machineId = %s", *machineIdPtr) 41 | machineId = *machineIdPtr 42 | } else { 43 | logrus.Debugf("nix: getting comin.machineId = null (not set)") 44 | machineId = "" 45 | } 46 | return 47 | } 48 | 49 | func runNixCommand(args []string, stdout, stderr io.Writer) (err error) { 50 | commonArgs := []string{"--extra-experimental-features", "nix-command", "--extra-experimental-features", "flakes", "--accept-flake-config"} 51 | args = append(commonArgs, args...) 52 | cmdStr := fmt.Sprintf("nix %s", strings.Join(args, " ")) 53 | logrus.Infof("nix: running '%s'", cmdStr) 54 | cmd := exec.Command("nix", args...) 55 | cmd.Stdout = stdout 56 | cmd.Stderr = stderr 57 | err = cmd.Run() 58 | if err != nil { 59 | return fmt.Errorf("command '%s' fails with %s", cmdStr, err) 60 | } 61 | return nil 62 | } 63 | 64 | func showDerivation(ctx context.Context, flakeUrl, hostname string) (drvPath string, outPath string, err error) { 65 | installable := fmt.Sprintf("%s#nixosConfigurations.%s.config.system.build.toplevel", flakeUrl, hostname) 66 | args := []string{ 67 | "show-derivation", 68 | installable, 69 | "-L", 70 | "--show-trace", 71 | } 72 | var stdout bytes.Buffer 73 | err = runNixCommand(args, &stdout, os.Stderr) 74 | if err != nil { 75 | return 76 | } 77 | 78 | var output map[string]Derivation 79 | err = json.Unmarshal(stdout.Bytes(), &output) 80 | if err != nil { 81 | return 82 | } 83 | keys := make([]string, 0, len(output)) 84 | for key := range output { 85 | keys = append(keys, key) 86 | } 87 | drvPath = keys[0] 88 | outPath = output[drvPath].Outputs.Out.Path 89 | logrus.Infof("nix: the derivation path is %s", drvPath) 90 | logrus.Infof("nix: the output path is %s", outPath) 91 | return 92 | } 93 | 94 | func build(ctx context.Context, drvPath string) (err error) { 95 | args := []string{ 96 | "build", 97 | fmt.Sprintf("%s^*", drvPath), 98 | "-L", 99 | "--no-link"} 100 | err = runNixCommand(args, os.Stdout, os.Stderr) 101 | if err != nil { 102 | return 103 | } 104 | return 105 | } 106 | 107 | func cominUnitFileHash() string { 108 | logrus.Infof("nix: generating the comin.service unit file sha256: 'systemctl cat comin.service | sha256sum'") 109 | cmd := exec.Command("systemctl", "cat", "comin.service") 110 | var stdout bytes.Buffer 111 | cmd.Stdout = &stdout 112 | cmd.Stderr = os.Stderr 113 | if err := cmd.Run(); err != nil { 114 | logrus.Infof("nix: command 'systemctl cat comin.service' fails with '%s'", err) 115 | return "" 116 | } 117 | sum := sha256.Sum256(stdout.Bytes()) 118 | hash := fmt.Sprintf("%x", sum) 119 | logrus.Infof("nix: the comin.service unit file sha256 is '%s'", hash) 120 | return hash 121 | } 122 | 123 | func switchToConfiguration(operation string, outPath string, dryRun bool) error { 124 | switchToConfigurationExe := filepath.Join(outPath, "bin", "switch-to-configuration") 125 | logrus.Infof("nix: running '%s %s'", switchToConfigurationExe, operation) 126 | cmd := exec.Command(switchToConfigurationExe, operation) 127 | cmd.Stdout = os.Stdout 128 | cmd.Stderr = os.Stderr 129 | if dryRun { 130 | logrus.Infof("nix: dry-run enabled: '%s switch' has not been executed", switchToConfigurationExe) 131 | } else { 132 | if err := cmd.Run(); err != nil { 133 | return fmt.Errorf("command %s switch fails with %s", switchToConfigurationExe, err) 134 | } 135 | logrus.Infof("nix: switch successfully terminated") 136 | } 137 | return nil 138 | } 139 | 140 | func deploy(ctx context.Context, outPath, operation string) (needToRestartComin bool, profilePath string, err error) { 141 | // FIXME: this check doesn't have to be here. It should be 142 | // done by the manager. 143 | beforeCominUnitFileHash := cominUnitFileHash() 144 | 145 | // This is required to write boot entries 146 | // Only do this is operation is switch or boot 147 | if profilePath, err = profile.SetSystemProfile(operation, outPath, false); err != nil { 148 | return 149 | } 150 | 151 | if err = switchToConfiguration(operation, outPath, false); err != nil { 152 | return 153 | } 154 | 155 | afterCominUnitFileHash := cominUnitFileHash() 156 | 157 | if beforeCominUnitFileHash != afterCominUnitFileHash { 158 | needToRestartComin = true 159 | } 160 | 161 | logrus.Infof("nix: deployment ended") 162 | 163 | return 164 | } 165 | -------------------------------------------------------------------------------- /internal/fetcher/fetcher.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/nlewo/comin/internal/repository" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type Fetcher struct { 12 | State 13 | submitRemotes chan []string 14 | RepositoryStatusCh chan repository.RepositoryStatus 15 | repo repository.Repository 16 | } 17 | 18 | func NewFetcher(repo repository.Repository) *Fetcher { 19 | f := &Fetcher{ 20 | repo: repo, 21 | submitRemotes: make(chan []string), 22 | RepositoryStatusCh: make(chan repository.RepositoryStatus), 23 | } 24 | f.RepositoryStatus = repo.GetRepositoryStatus() 25 | return f 26 | 27 | } 28 | 29 | func (f *Fetcher) TriggerFetch(remotes []string) { 30 | f.submitRemotes <- remotes 31 | } 32 | 33 | type RemoteState struct { 34 | Name string `json:"name"` 35 | FetchedAt time.Time `json:"fetched_at"` 36 | } 37 | type State struct { 38 | IsFetching bool `jsona:"is_fetching"` 39 | RepositoryStatus repository.RepositoryStatus 40 | } 41 | 42 | // FIXME: make it thread safe 43 | func (f *Fetcher) GetState() State { 44 | return State{ 45 | IsFetching: f.IsFetching, 46 | RepositoryStatus: f.RepositoryStatus, 47 | } 48 | } 49 | 50 | func (f *Fetcher) Start() { 51 | logrus.Info("fetcher: starting") 52 | go func() { 53 | remotes := make([]string, 0) 54 | var workerRepositoryStatusCh chan repository.RepositoryStatus 55 | for { 56 | select { 57 | case submittedRemotes := <-f.submitRemotes: 58 | logrus.Debugf("fetch: remotes submitted: %s", submittedRemotes) 59 | remotes = union(remotes, submittedRemotes) 60 | case rs := <-workerRepositoryStatusCh: 61 | f.IsFetching = false 62 | if rs.SelectedCommitId != f.RepositoryStatus.SelectedCommitId || rs.SelectedBranchIsTesting != f.RepositoryStatus.SelectedBranchIsTesting { 63 | f.RepositoryStatus = rs 64 | f.RepositoryStatusCh <- rs 65 | } 66 | } 67 | if !f.IsFetching && len(remotes) != 0 { 68 | f.IsFetching = true 69 | workerRepositoryStatusCh = f.repo.FetchAndUpdate(context.TODO(), remotes) 70 | remotes = []string{} 71 | } 72 | } 73 | }() 74 | } 75 | 76 | func union(array1, array2 []string) []string { 77 | for _, e2 := range array2 { 78 | exist := false 79 | for _, e1 := range array1 { 80 | if e2 == e1 { 81 | exist = true 82 | break 83 | } 84 | } 85 | if !exist { 86 | array1 = append(array1, e2) 87 | } 88 | } 89 | return array1 90 | } 91 | -------------------------------------------------------------------------------- /internal/fetcher/fetcher_test.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/nlewo/comin/internal/repository" 9 | "github.com/nlewo/comin/internal/utils" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestFetcher(t *testing.T) { 14 | r := utils.NewRepositoryMock() 15 | f := NewFetcher(r) 16 | f.Start() 17 | var commitId string 18 | 19 | for i := 0; i < 2; i++ { 20 | assert.False(t, f.IsFetching) 21 | f.TriggerFetch([]string{"remote"}) 22 | 23 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 24 | assert.True(c, f.IsFetching) 25 | }, 5*time.Second, 100*time.Millisecond, "fetcher is not fetching") 26 | 27 | // This is to simulate a git fetch 28 | commitId = fmt.Sprintf("id-%d", i) 29 | r.RsCh <- repository.RepositoryStatus{ 30 | SelectedCommitId: commitId, 31 | } 32 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 33 | rs := <-f.RepositoryStatusCh 34 | assert.Equal(c, commitId, rs.SelectedCommitId) 35 | }, 5*time.Second, 100*time.Millisecond, "fetcher failed to fetch") 36 | 37 | assert.False(t, f.IsFetching) 38 | } 39 | 40 | f.TriggerFetch([]string{"remote"}) 41 | r.RsCh <- repository.RepositoryStatus{ 42 | SelectedCommitId: "id-5", 43 | } 44 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 45 | rs := <-f.RepositoryStatusCh 46 | assert.Equal(c, "id-5", rs.SelectedCommitId) 47 | }, 5*time.Second, 100*time.Millisecond, "fetcher failed to fetch") 48 | 49 | r.RsCh <- repository.RepositoryStatus{ 50 | SelectedCommitId: "id-5", 51 | } 52 | r.RsCh <- repository.RepositoryStatus{ 53 | SelectedCommitId: "id-6", 54 | } 55 | rs := <-f.RepositoryStatusCh 56 | assert.NotEqual(t, "id-5", rs.SelectedCommitId) 57 | } 58 | 59 | func TestUnion(t *testing.T) { 60 | res := union([]string{"r1", "r2"}, []string{"r1", "r3"}) 61 | assert.Equal(t, []string{"r1", "r2", "r3"}, res) 62 | } 63 | -------------------------------------------------------------------------------- /internal/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/nlewo/comin/internal/manager" 11 | "github.com/nlewo/comin/internal/prometheus" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func handlerStatus(m *manager.Manager, w http.ResponseWriter, r *http.Request) { 16 | logrus.Debugf("Getting status request %s from %s", r.URL, r.RemoteAddr) 17 | w.WriteHeader(http.StatusOK) 18 | s := m.GetState() 19 | logrus.Debugf("Manager state is %#v", s) 20 | rJson, err := json.MarshalIndent(s, "", "\t") 21 | if err != nil { 22 | logrus.Error(err) 23 | } 24 | _, _ = io.Writer.Write(w, rJson) 25 | } 26 | 27 | // Serve starts http servers. We create two HTTP servers to easily be 28 | // able to expose metrics publicly while keeping on localhost only the 29 | // API. 30 | func Serve(m *manager.Manager, p prometheus.Prometheus, apiAddress string, apiPort int, metricsAddress string, metricsPort int) { 31 | handlerStatusFn := func(w http.ResponseWriter, r *http.Request) { 32 | handlerStatus(m, w, r) 33 | } 34 | handlerFetcherFn := func(w http.ResponseWriter, r *http.Request) { 35 | w.WriteHeader(http.StatusOK) 36 | s := m.GetState().Fetcher 37 | rJson, _ := json.MarshalIndent(s, "", "\t") 38 | _, _ = io.Writer.Write(w, rJson) 39 | } 40 | handlerFetcherFetchFn := func(w http.ResponseWriter, r *http.Request) { 41 | switch r.Method { 42 | case http.MethodPost: 43 | s := m.GetState().Fetcher 44 | remotes := make([]string, 0) 45 | for _, r := range s.RepositoryStatus.Remotes { 46 | remotes = append(remotes, r.Name) 47 | } 48 | m.Fetcher.TriggerFetch(remotes) 49 | w.WriteHeader(http.StatusOK) 50 | default: 51 | w.WriteHeader(http.StatusMethodNotAllowed) 52 | } 53 | } 54 | 55 | muxApi := http.NewServeMux() 56 | muxApi.HandleFunc("/api/status", handlerStatusFn) 57 | muxApi.HandleFunc("/api/fetcher", handlerFetcherFn) 58 | muxApi.HandleFunc("/api/fetcher/fetch", handlerFetcherFetchFn) 59 | muxMetrics := http.NewServeMux() 60 | muxMetrics.Handle("/metrics", p.Handler()) 61 | 62 | go func() { 63 | url := fmt.Sprintf("%s:%d", apiAddress, apiPort) 64 | logrus.Infof("Starting the API server on %s", url) 65 | if err := http.ListenAndServe(url, muxApi); err != nil { 66 | logrus.Errorf("Error while running the API server: %s", err) 67 | os.Exit(1) 68 | } 69 | }() 70 | go func() { 71 | url := fmt.Sprintf("%s:%d", metricsAddress, metricsPort) 72 | logrus.Infof("Starting the metrics server on %s", url) 73 | if err := http.ListenAndServe(url, muxMetrics); err != nil { 74 | logrus.Errorf("Error while running the metrics server: %s", err) 75 | os.Exit(1) 76 | } 77 | }() 78 | } 79 | -------------------------------------------------------------------------------- /internal/manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/nlewo/comin/internal/builder" 5 | "github.com/nlewo/comin/internal/deployer" 6 | "github.com/nlewo/comin/internal/fetcher" 7 | "github.com/nlewo/comin/internal/profile" 8 | "github.com/nlewo/comin/internal/prometheus" 9 | "github.com/nlewo/comin/internal/scheduler" 10 | "github.com/nlewo/comin/internal/store" 11 | "github.com/nlewo/comin/internal/utils" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type State struct { 16 | NeedToReboot bool `json:"need_to_reboot"` 17 | Fetcher fetcher.State `json:"fetcher"` 18 | Builder builder.State `json:"builder"` 19 | Deployer deployer.State `json:"deployer"` 20 | } 21 | 22 | type Manager struct { 23 | // The machine id of the current host. It is used to ensure 24 | // the optionnal machine-id found at evaluation time 25 | // corresponds to the machine-id of this host. 26 | machineId string 27 | 28 | stateRequestCh chan struct{} 29 | stateResultCh chan State 30 | 31 | needToReboot bool 32 | cominServiceRestartFunc func() error 33 | 34 | prometheus prometheus.Prometheus 35 | storage store.Store 36 | scheduler scheduler.Scheduler 37 | Fetcher *fetcher.Fetcher 38 | builder *builder.Builder 39 | deployer *deployer.Deployer 40 | } 41 | 42 | func New(s store.Store, p prometheus.Prometheus, sched scheduler.Scheduler, fetcher *fetcher.Fetcher, builder *builder.Builder, deployer *deployer.Deployer, machineId string) *Manager { 43 | m := &Manager{ 44 | machineId: machineId, 45 | stateRequestCh: make(chan struct{}), 46 | stateResultCh: make(chan State), 47 | cominServiceRestartFunc: utils.CominServiceRestart, 48 | prometheus: p, 49 | storage: s, 50 | scheduler: sched, 51 | Fetcher: fetcher, 52 | builder: builder, 53 | deployer: deployer, 54 | } 55 | return m 56 | } 57 | 58 | func (m *Manager) GetState() State { 59 | m.stateRequestCh <- struct{}{} 60 | return <-m.stateResultCh 61 | } 62 | 63 | func (m *Manager) toState() State { 64 | return State{ 65 | NeedToReboot: m.needToReboot, 66 | Fetcher: m.Fetcher.GetState(), 67 | Builder: m.builder.State(), 68 | Deployer: m.deployer.State(), 69 | } 70 | } 71 | 72 | // FetchAndBuild fetches new commits. If a new commit is available, it 73 | // evaluates and builds the derivation. Once built, it pushes the 74 | // generation on a channel which is consumed by the deployer. 75 | func (m *Manager) FetchAndBuild() { 76 | go func() { 77 | for { 78 | select { 79 | case rs := <-m.Fetcher.RepositoryStatusCh: 80 | if !rs.SelectedCommitShouldBeSigned || rs.SelectedCommitSigned { 81 | logrus.Infof("manager: a generation is evaluating for commit %s", rs.SelectedCommitId) 82 | m.builder.Eval(rs) 83 | } else { 84 | logrus.Infof("manager: the commit %s is not evaluated because it is not signed", rs.SelectedCommitId) 85 | } 86 | case generation := <-m.builder.EvaluationDone: 87 | if generation.EvalErr != nil { 88 | continue 89 | } 90 | if generation.MachineId != "" && m.machineId != generation.MachineId { 91 | logrus.Infof("manager: the comin.machineId %s is not the host machine-id %s", generation.MachineId, m.machineId) 92 | } else { 93 | logrus.Infof("manager: a generation is building for commit %s", generation.SelectedCommitId) 94 | _ = m.builder.Build() 95 | } 96 | case generation := <-m.builder.BuildDone: 97 | if generation.BuildErr == nil { 98 | logrus.Infof("manager: a generation is available for deployment with commit %s", generation.SelectedCommitId) 99 | m.deployer.Submit(generation) 100 | } 101 | } 102 | } 103 | 104 | }() 105 | } 106 | 107 | func (m *Manager) Run() { 108 | logrus.Infof("manager: starting with machineId=%s", m.machineId) 109 | m.needToReboot = utils.NeedToReboot() 110 | m.prometheus.SetHostInfo(m.needToReboot) 111 | 112 | m.FetchAndBuild() 113 | m.deployer.Run() 114 | 115 | for { 116 | select { 117 | case <-m.stateRequestCh: 118 | m.stateResultCh <- m.toState() 119 | case dpl := <-m.deployer.DeploymentDoneCh: 120 | m.prometheus.SetDeploymentInfo(dpl.Generation.SelectedCommitId, deployer.StatusToString(dpl.Status)) 121 | getsEvicted, evicted := m.storage.DeploymentInsertAndCommit(dpl) 122 | if getsEvicted && evicted.ProfilePath != "" { 123 | _ = profile.RemoveProfilePath(evicted.ProfilePath) 124 | } 125 | m.needToReboot = utils.NeedToReboot() 126 | m.prometheus.SetHostInfo(m.needToReboot) 127 | if dpl.RestartComin { 128 | // TODO: stop contexts 129 | if err := m.cominServiceRestartFunc(); err != nil { 130 | logrus.Fatal(err) 131 | return 132 | } 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /internal/manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/nlewo/comin/internal/builder" 10 | "github.com/nlewo/comin/internal/deployer" 11 | "github.com/nlewo/comin/internal/fetcher" 12 | "github.com/nlewo/comin/internal/prometheus" 13 | "github.com/nlewo/comin/internal/repository" 14 | "github.com/nlewo/comin/internal/scheduler" 15 | "github.com/nlewo/comin/internal/store" 16 | "github.com/nlewo/comin/internal/utils" 17 | "github.com/sirupsen/logrus" 18 | "github.com/stretchr/testify/assert" 19 | ) 20 | 21 | var mkNixEvalMock = func(evalOk chan bool) builder.EvalFunc { 22 | return func(ctx context.Context, repositoryPath string, hostname string) (string, string, string, error) { 23 | ok := <-evalOk 24 | if ok { 25 | return "drv-path", "out-path", "", nil 26 | } else { 27 | return "", "", "", fmt.Errorf("An error occured") 28 | } 29 | } 30 | } 31 | 32 | var mkDeployerMock = func() *deployer.Deployer { 33 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 34 | return false, "", nil 35 | } 36 | return deployer.New(deployFunc, nil, "") 37 | } 38 | 39 | var mkNixBuildMock = func(buildOk chan bool) builder.BuildFunc { 40 | return func(ctx context.Context, drvPath string) error { 41 | select { 42 | case <-ctx.Done(): 43 | return ctx.Err() 44 | case ok := <-buildOk: 45 | if ok { 46 | return nil 47 | } else { 48 | return fmt.Errorf("An error occured") 49 | } 50 | } 51 | } 52 | } 53 | 54 | func TestBuild(t *testing.T) { 55 | evalOk := make(chan bool) 56 | buildOk := make(chan bool) 57 | logrus.SetLevel(logrus.DebugLevel) 58 | r := utils.NewRepositoryMock() 59 | f := fetcher.NewFetcher(r) 60 | f.Start() 61 | b := builder.New("repoPath", "", "my-machine", 2*time.Second, mkNixEvalMock(evalOk), 2*time.Second, mkNixBuildMock(buildOk)) 62 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 63 | return false, "profile-path", nil 64 | } 65 | d := deployer.New(deployFunc, nil, "") 66 | m := New(store.New("", 1, 1), prometheus.New(), scheduler.New(), f, b, d, "") 67 | go m.Run() 68 | assert.False(t, m.Fetcher.GetState().IsFetching) 69 | assert.False(t, m.builder.State().IsEvaluating) 70 | assert.False(t, m.builder.State().IsBuilding) 71 | 72 | commitId := "id-1" 73 | f.TriggerFetch([]string{"remote"}) 74 | r.RsCh <- repository.RepositoryStatus{ 75 | SelectedCommitId: commitId, 76 | } 77 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 78 | assert.True(c, m.builder.State().IsEvaluating) 79 | assert.False(c, m.builder.State().IsBuilding) 80 | }, 5*time.Second, 100*time.Millisecond) 81 | 82 | // This simulates the failure of an evaluation 83 | evalOk <- false 84 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 85 | assert.False(c, m.builder.State().IsEvaluating) 86 | assert.False(c, m.builder.State().IsBuilding) 87 | assert.NotNil(c, m.builder.GetGeneration().EvalErr) 88 | assert.Nil(c, m.deployer.GenerationToDeploy) 89 | }, 5*time.Second, 100*time.Millisecond) 90 | 91 | commitId = "id-2" 92 | f.TriggerFetch([]string{"remote"}) 93 | r.RsCh <- repository.RepositoryStatus{ 94 | SelectedCommitId: commitId, 95 | } 96 | // This simulates the success of an evaluation 97 | evalOk <- true 98 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 99 | assert.False(c, m.builder.State().IsEvaluating) 100 | assert.True(c, m.builder.State().IsBuilding) 101 | assert.Nil(c, m.builder.GetGeneration().EvalErr) 102 | assert.Nil(c, m.deployer.GenerationToDeploy) 103 | }, 5*time.Second, 100*time.Millisecond) 104 | 105 | // This simulates the failure of a build 106 | buildOk <- false 107 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 108 | assert.False(c, m.builder.State().IsEvaluating) 109 | assert.False(c, m.builder.State().IsBuilding) 110 | assert.NotNil(c, m.builder.GetGeneration().BuildErr) 111 | assert.Nil(c, m.deployer.GenerationToDeploy) 112 | }, 5*time.Second, 100*time.Millisecond) 113 | 114 | // This simulates the success of a build 115 | f.TriggerFetch([]string{"remote"}) 116 | r.RsCh <- repository.RepositoryStatus{ 117 | SelectedCommitId: "id-3", 118 | } 119 | evalOk <- true 120 | buildOk <- true 121 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 122 | assert.False(c, m.builder.State().IsEvaluating) 123 | assert.False(c, m.builder.State().IsBuilding) 124 | assert.Nil(c, m.builder.GetGeneration().BuildErr) 125 | }, 5*time.Second, 100*time.Millisecond) 126 | 127 | // This simulates the success of another build and ensure this 128 | // new build is the one proposed for deployment. 129 | f.TriggerFetch([]string{"remote"}) 130 | r.RsCh <- repository.RepositoryStatus{ 131 | SelectedCommitId: "id-4", 132 | } 133 | evalOk <- true 134 | buildOk <- true 135 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 136 | assert.False(c, m.builder.State().IsEvaluating) 137 | assert.False(c, m.builder.State().IsBuilding) 138 | assert.Nil(c, m.builder.GetGeneration().BuildErr) 139 | }, 5*time.Second, 100*time.Millisecond) 140 | 141 | // This simulates the push of new commit while building 142 | f.TriggerFetch([]string{"remote"}) 143 | r.RsCh <- repository.RepositoryStatus{ 144 | SelectedCommitId: "id-5", 145 | } 146 | evalOk <- true 147 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 148 | assert.True(c, m.builder.State().IsBuilding) 149 | }, 5*time.Second, 100*time.Millisecond) 150 | } 151 | 152 | func TestDeploy(t *testing.T) { 153 | evalOk := make(chan bool) 154 | buildOk := make(chan bool) 155 | logrus.SetLevel(logrus.DebugLevel) 156 | r := utils.NewRepositoryMock() 157 | f := fetcher.NewFetcher(r) 158 | f.Start() 159 | b := builder.New("repoPath", "", "my-machine", 2*time.Second, mkNixEvalMock(evalOk), 2*time.Second, mkNixBuildMock(buildOk)) 160 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 161 | return false, "profile-path", nil 162 | } 163 | d := deployer.New(deployFunc, nil, "") 164 | m := New(store.New("", 1, 1), prometheus.New(), scheduler.New(), f, b, d, "") 165 | go m.Run() 166 | assert.False(t, m.Fetcher.GetState().IsFetching) 167 | assert.False(t, m.builder.State().IsEvaluating) 168 | assert.False(t, m.builder.State().IsBuilding) 169 | 170 | m.deployer.Submit(builder.Generation{}) 171 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 172 | assert.Equal(c, "profile-path", m.deployer.State().Deployment.ProfilePath) 173 | }, 5*time.Second, 100*time.Millisecond) 174 | 175 | } 176 | 177 | func TestRestartComin(t *testing.T) { 178 | evalOk := make(chan bool) 179 | buildOk := make(chan bool) 180 | logrus.SetLevel(logrus.DebugLevel) 181 | r := utils.NewRepositoryMock() 182 | f := fetcher.NewFetcher(r) 183 | f.Start() 184 | b := builder.New("repoPath", "", "my-machine", 2*time.Second, mkNixEvalMock(evalOk), 2*time.Second, mkNixBuildMock(buildOk)) 185 | var deployFunc = func(context.Context, string, string) (bool, string, error) { 186 | return true, "profile-path", nil 187 | } 188 | d := deployer.New(deployFunc, nil, "") 189 | m := New(store.New("", 1, 1), prometheus.New(), scheduler.New(), f, b, d, "") 190 | go m.Run() 191 | 192 | isCominRestarted := false 193 | cominServiceRestartMock := func() error { 194 | isCominRestarted = true 195 | return nil 196 | } 197 | m.cominServiceRestartFunc = cominServiceRestartMock 198 | 199 | m.deployer.Submit(builder.Generation{}) 200 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 201 | assert.True(c, isCominRestarted) 202 | }, 5*time.Second, 100*time.Millisecond, "comin has not been restarted yet") 203 | } 204 | 205 | func TestIncorrectMachineId(t *testing.T) { 206 | buildOk := make(chan bool) 207 | logrus.SetLevel(logrus.DebugLevel) 208 | r := utils.NewRepositoryMock() 209 | f := fetcher.NewFetcher(r) 210 | f.Start() 211 | nixEval := func(ctx context.Context, repositoryPath string, hostname string) (string, string, string, error) { 212 | return "drv-path", "out-path", "invalid-machine-id", nil 213 | } 214 | b := builder.New("repoPath", "", "my-machine", 2*time.Second, nixEval, 2*time.Second, mkNixBuildMock(buildOk)) 215 | d := mkDeployerMock() 216 | m := New(store.New("", 1, 1), prometheus.New(), scheduler.New(), f, b, d, "the-test-machine-id") 217 | go m.Run() 218 | 219 | f.TriggerFetch([]string{"remote"}) 220 | r.RsCh <- repository.RepositoryStatus{ 221 | SelectedCommitId: "id", 222 | } 223 | 224 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 225 | assert.False(t, m.GetState().Builder.IsBuilding) 226 | }, 5*time.Second, 100*time.Millisecond) 227 | } 228 | 229 | func TestCorrectMachineId(t *testing.T) { 230 | buildOk := make(chan bool) 231 | logrus.SetLevel(logrus.DebugLevel) 232 | r := utils.NewRepositoryMock() 233 | f := fetcher.NewFetcher(r) 234 | f.Start() 235 | nixEval := func(ctx context.Context, repositoryPath string, hostname string) (string, string, string, error) { 236 | return "drv-path", "out-path", "the-test-machine-id", nil 237 | } 238 | b := builder.New("repoPath", "", "my-machine", 2*time.Second, nixEval, 2*time.Second, mkNixBuildMock(buildOk)) 239 | d := mkDeployerMock() 240 | m := New(store.New("", 1, 1), prometheus.New(), scheduler.New(), f, b, d, "the-test-machine-id") 241 | go m.Run() 242 | 243 | f.TriggerFetch([]string{"remote"}) 244 | r.RsCh <- repository.RepositoryStatus{ 245 | SelectedCommitId: "id", 246 | } 247 | 248 | assert.EventuallyWithT(t, func(c *assert.CollectT) { 249 | assert.True(t, m.GetState().Builder.IsBuilding) 250 | }, 5*time.Second, 100*time.Millisecond) 251 | } 252 | -------------------------------------------------------------------------------- /internal/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import "os" 4 | import "fmt" 5 | import "github.com/sirupsen/logrus" 6 | import "os/exec" 7 | import "path" 8 | 9 | var systemProfilesDir string = "/nix/var/nix/profiles/system-profiles" 10 | var cominProfileDir string = systemProfilesDir + "/comin" 11 | 12 | // setSystemProfile creates a link into the directory 13 | // /nix/var/nix/profiles/system-profiles/comin to the built system 14 | // store path. This is used by the switch-to-configuration script to 15 | // install all entries into the bootloader. 16 | // Note also comin uses these links as gcroots 17 | // See https://github.com/nixos/nixpkgs/blob/df98ab81f908bed57c443a58ec5230f7f7de9bd3/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh#L711 18 | // and https://github.com/nixos/nixpkgs/blob/df98ab81f908bed57c443a58ec5230f7f7de9bd3/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py#L247 19 | func SetSystemProfile(operation string, outPath string, dryRun bool) (profilePath string, err error) { 20 | if operation == "switch" || operation == "boot" { 21 | err := os.MkdirAll(systemProfilesDir, os.ModeDir) 22 | if err != nil && !os.IsExist(err) { 23 | return profilePath, fmt.Errorf("nix: failed to create the profile directory: %s", systemProfilesDir) 24 | } 25 | cmdStr := fmt.Sprintf("nix-env --profile %s --set %s", cominProfileDir, outPath) 26 | logrus.Infof("nix: running '%s'", cmdStr) 27 | cmd := exec.Command("nix-env", "--profile", cominProfileDir, "--set", outPath) 28 | cmd.Stdout = os.Stdout 29 | cmd.Stderr = os.Stderr 30 | if dryRun { 31 | logrus.Infof("nix: dry-run enabled: '%s' has not been executed", cmdStr) 32 | } else { 33 | err := cmd.Run() 34 | if err != nil { 35 | return profilePath, fmt.Errorf("nix: command '%s' fails with %s", cmdStr, err) 36 | } 37 | logrus.Infof("nix: command '%s' succeeded", cmdStr) 38 | dst, err := os.Readlink(cominProfileDir) 39 | if err != nil { 40 | return profilePath, fmt.Errorf("nix: failed to os.Readlink(%s)", cominProfileDir) 41 | } 42 | profilePath = path.Join(systemProfilesDir, dst) 43 | logrus.Infof("nix: the profile %s has been created", profilePath) 44 | } 45 | } 46 | return 47 | } 48 | 49 | // RemoveProfilePath removes a profile path. 50 | func RemoveProfilePath(profilePath string) (err error) { 51 | logrus.Infof("Removing profile path %s", profilePath) 52 | err = os.Remove(profilePath) 53 | if err != nil { 54 | logrus.Errorf("Failed to remove profile path %s: %s", profilePath, err) 55 | } 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /internal/profile/profile_test.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "path" 5 | "testing" 6 | 7 | "os" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRemoveProfilePath(t *testing.T) { 13 | dir := t.TempDir() 14 | file1 := path.Join(dir, "file1") 15 | _, _ = os.Create(file1) 16 | file2 := path.Join(dir, "file2") 17 | _, _ = os.Create(file2) 18 | 19 | _ = RemoveProfilePath(file1) 20 | entries, _ := os.ReadDir(dir) 21 | files := make([]string, len(entries)) 22 | for i, e := range entries { 23 | files[i] = e.Name() 24 | } 25 | expected := []string{"file2"} 26 | assert.Equal(t, expected, files) 27 | } 28 | -------------------------------------------------------------------------------- /internal/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promhttp" 8 | ) 9 | 10 | type Prometheus struct { 11 | promRegistry *prometheus.Registry 12 | buildInfo *prometheus.GaugeVec 13 | deploymentInfo *prometheus.GaugeVec 14 | fetchCounter *prometheus.CounterVec 15 | hostInfo *prometheus.GaugeVec 16 | } 17 | 18 | func New() Prometheus { 19 | promReg := prometheus.NewRegistry() 20 | buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 21 | Name: "comin_build_info", 22 | Help: "Build info for comin.", 23 | }, []string{"version"}) 24 | deploymentInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 25 | Name: "comin_deployment_info", 26 | Help: "Info of the last deployment.", 27 | }, []string{"commit_id", "status"}) 28 | fetchCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ 29 | Name: "comin_fetch_count", 30 | Help: "Number of fetches per status", 31 | }, []string{"remote_name", "status"}) 32 | hostInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 33 | Name: "comin_host_info", 34 | Help: "Info of the host.", 35 | }, []string{"need_to_reboot"}) 36 | promReg.MustRegister(buildInfo) 37 | promReg.MustRegister(deploymentInfo) 38 | promReg.MustRegister(fetchCounter) 39 | promReg.MustRegister(hostInfo) 40 | return Prometheus{ 41 | promRegistry: promReg, 42 | buildInfo: buildInfo, 43 | deploymentInfo: deploymentInfo, 44 | fetchCounter: fetchCounter, 45 | hostInfo: hostInfo, 46 | } 47 | } 48 | 49 | func (m Prometheus) Handler() http.Handler { 50 | return promhttp.HandlerFor( 51 | m.promRegistry, 52 | promhttp.HandlerOpts{ 53 | EnableOpenMetrics: false, 54 | }) 55 | } 56 | 57 | func (m Prometheus) IncFetchCounter(remoteName, status string) { 58 | m.fetchCounter.With(prometheus.Labels{"remote_name": remoteName, "status": status}).Inc() 59 | } 60 | 61 | func (m Prometheus) SetBuildInfo(version string) { 62 | m.buildInfo.Reset() 63 | m.buildInfo.With(prometheus.Labels{"version": version}).Set(1) 64 | } 65 | 66 | func (m Prometheus) SetDeploymentInfo(commitId, status string) { 67 | m.deploymentInfo.Reset() 68 | m.deploymentInfo.With(prometheus.Labels{"commit_id": commitId, "status": status}).Set(1) 69 | } 70 | 71 | func (m Prometheus) SetHostInfo(needToReboot bool) { 72 | m.hostInfo.Reset() 73 | var value string 74 | if needToReboot { 75 | value = "1" 76 | } else { 77 | value = "0" 78 | } 79 | m.hostInfo.With(prometheus.Labels{"need_to_reboot": value}).Set(1) 80 | } 81 | -------------------------------------------------------------------------------- /internal/repository/fail.public: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mDMEZ4oDaRYJKwYBBAHaRw8BAQdA91zbRSdMphKMs7wP+3/mOpDkxEfeWrfblS5t 4 | uf5xw1O0F2ZhaWwgPGZhaWxAY29taW4uc3BhY2U+iJQEExYKADwWIQSNo3AzK05c 5 | jADI4rwfTCYbHTKLkgUCZ4oDaQIbAwUJBaOagAQLCQgHBBUKCQgFFgIDAQACHgUC 6 | F4AACgkQH0wmGx0yi5IEyAD/ck8A4aPUK8+g7EzMLRnl+twUccwmS7wIthLsA7Sm 7 | s0sA/2RMyImXOK82hesQi8VqV/XNsu/n5Lg6bAfkTHQR1CwLuDgEZ4oDaRIKKwYB 8 | BAGXVQEFAQEHQLr2P/jpdMyluCmFv1mmtHxNy4rOAstT61B+Zsq+8/wtAwEIB4h+ 9 | BBgWCgAmFiEEjaNwMytOXIwAyOK8H0wmGx0yi5IFAmeKA2kCGwwFCQWjmoAACgkQ 10 | H0wmGx0yi5IXxwD6AwMQTzw4uXuMJiNC3lsaX5+L9vJDy4tSu/bufc4EKPoA/iiu 11 | kbksGGr4c6gTHOovFhEklvJhjPcEcwdvdEnioWgL 12 | =zjq4 13 | -----END PGP PUBLIC KEY BLOCK----- 14 | -------------------------------------------------------------------------------- /internal/repository/git.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/ProtonMail/go-crypto/openpgp" 9 | "github.com/go-git/go-git/v5" 10 | gitConfig "github.com/go-git/go-git/v5/config" 11 | "github.com/go-git/go-git/v5/plumbing" 12 | "github.com/go-git/go-git/v5/plumbing/object" 13 | "github.com/go-git/go-git/v5/plumbing/transport/http" 14 | "github.com/nlewo/comin/internal/types" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | func RepositoryClone(directory, url, commitId, accessToken string) error { 19 | options := &git.CloneOptions{ 20 | URL: url, 21 | NoCheckout: true, 22 | } 23 | if accessToken != "" { 24 | options.Auth = &http.BasicAuth{ 25 | Username: "comin", 26 | Password: accessToken, 27 | } 28 | } 29 | repository, err := git.PlainClone(directory, false, options) 30 | if err != nil { 31 | return err 32 | } 33 | worktree, err := repository.Worktree() 34 | if err != nil { 35 | return err 36 | } 37 | err = worktree.Checkout(&git.CheckoutOptions{ 38 | Hash: plumbing.NewHash(commitId), 39 | }) 40 | if err != nil { 41 | return fmt.Errorf("cannot checkout the commit ID %s: '%s'", commitId, err) 42 | } 43 | return nil 44 | } 45 | 46 | func getRemoteCommitHash(r repository, remote, branch string) *plumbing.Hash { 47 | remoteBranch := fmt.Sprintf("refs/remotes/%s/%s", remote, branch) 48 | remoteHeadRef, err := r.Repository.Reference( 49 | plumbing.ReferenceName(remoteBranch), 50 | true) 51 | if err != nil { 52 | return nil 53 | } 54 | if remoteHeadRef == nil { 55 | return nil 56 | } 57 | commitId := remoteHeadRef.Hash() 58 | return &commitId 59 | } 60 | 61 | func hasNotBeenHardReset(r repository, branchName string, currentMainHash *plumbing.Hash, remoteMainHead *plumbing.Hash) error { 62 | if currentMainHash != nil && remoteMainHead != nil && *currentMainHash != *remoteMainHead { 63 | var ok bool 64 | ok, err := isAncestor(r.Repository, *currentMainHash, *remoteMainHead) 65 | if err != nil { 66 | return err 67 | } 68 | if !ok { 69 | return fmt.Errorf("this branch has been hard reset: its head '%s' is not on top of '%s'", 70 | remoteMainHead.String(), currentMainHash.String()) 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | func getHeadFromRemoteAndBranch(r repository, remoteName, branchName, currentMainCommitId string) (newHead plumbing.Hash, msg string, err error) { 77 | var currentMainHash *plumbing.Hash 78 | head := getRemoteCommitHash(r, remoteName, branchName) 79 | if head == nil { 80 | return newHead, "", fmt.Errorf("the branch '%s/%s' doesn't exist", remoteName, branchName) 81 | } 82 | if currentMainCommitId != "" { 83 | c := plumbing.NewHash(currentMainCommitId) 84 | currentMainHash = &c 85 | } 86 | 87 | if err = hasNotBeenHardReset(r, branchName, currentMainHash, head); err != nil { 88 | return 89 | } 90 | 91 | commitObject, err := r.Repository.CommitObject(*head) 92 | if err != nil { 93 | return 94 | } 95 | 96 | return *head, commitObject.Message, nil 97 | } 98 | 99 | func hardReset(r repository, newHead plumbing.Hash) error { 100 | var w *git.Worktree 101 | w, err := r.Repository.Worktree() 102 | if err != nil { 103 | return fmt.Errorf("failed to get the worktree") 104 | } 105 | err = w.Checkout(&git.CheckoutOptions{ 106 | Hash: newHead, 107 | Force: true, 108 | }) 109 | if err != nil { 110 | return fmt.Errorf("git reset --hard %s fails: '%s'", newHead, err) 111 | } 112 | return nil 113 | } 114 | 115 | // fetch fetches the config.Remote 116 | func fetch(r repository, remote types.Remote) (err error) { 117 | logrus.Debugf("Fetching remote '%s'", remote.Name) 118 | fetchOptions := git.FetchOptions{ 119 | RemoteName: remote.Name, 120 | } 121 | // TODO: support several authentication methods 122 | if remote.Auth.AccessToken != "" { 123 | fetchOptions.Auth = &http.BasicAuth{ 124 | // On GitLab, any non blank username is 125 | // working. 126 | Username: "comin", 127 | Password: remote.Auth.AccessToken, 128 | } 129 | } 130 | 131 | // TODO: we should get a parent context 132 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(remote.Timeout)*time.Second) 133 | defer cancel() 134 | // TODO: we should only fetch tracked branches 135 | err = r.Repository.FetchContext(ctx, &fetchOptions) 136 | if err == nil { 137 | logrus.Infof("New commits have been fetched from '%s'", remote.URL) 138 | return nil 139 | } else if err != git.NoErrAlreadyUpToDate { 140 | logrus.Errorf("Pull from remote '%s' failed: %s", remote.Name, err) 141 | return fmt.Errorf("'git fetch %s' fails: '%s'", remote.Name, err) 142 | } else { 143 | logrus.Debugf("No new commits have been fetched from the remote '%s'", remote.Name) 144 | return nil 145 | } 146 | } 147 | 148 | // isAncestor returns true when the commitId is an ancestor of the branch branchName 149 | func isAncestor(r *git.Repository, base, top plumbing.Hash) (found bool, err error) { 150 | iter, err := r.Log(&git.LogOptions{From: top}) 151 | if err != nil { 152 | return false, fmt.Errorf("git log %s fails: '%s'", top, err) 153 | } 154 | 155 | // To skip the first commit 156 | isFirst := true 157 | _ = iter.ForEach(func(commit *object.Commit) error { 158 | if !isFirst && commit.Hash == base { 159 | found = true 160 | // This error is ignored and used to terminate early the loop :/ 161 | return fmt.Errorf("base commit is ancestor of top commit") 162 | } 163 | isFirst = false 164 | return nil 165 | }) 166 | return 167 | } 168 | 169 | func repositoryOpen(config types.GitConfig) (r *git.Repository, err error) { 170 | r, err = git.PlainInit(config.Path, false) 171 | if err != nil { 172 | r, err = git.PlainOpen(config.Path) 173 | if err != nil { 174 | return 175 | } 176 | logrus.Debugf("The local Git repository located at '%s' has been opened", config.Path) 177 | } else { 178 | logrus.Infof("The local Git repository located at '%s' has been initialized", config.Path) 179 | } 180 | return 181 | } 182 | 183 | func manageRemotes(r *git.Repository, remotes []types.Remote) error { 184 | for _, remote := range remotes { 185 | if err := manageRemote(r, remote); err != nil { 186 | return err 187 | } 188 | } 189 | return nil 190 | } 191 | 192 | func manageRemote(r *git.Repository, remote types.Remote) error { 193 | gitRemote, err := r.Remote(remote.Name) 194 | if err == git.ErrRemoteNotFound { 195 | logrus.Infof("Adding remote '%s' with url '%s'", remote.Name, remote.URL) 196 | _, err = r.CreateRemote(&gitConfig.RemoteConfig{ 197 | Name: remote.Name, 198 | URLs: []string{remote.URL}, 199 | }) 200 | if err != nil { 201 | return err 202 | } 203 | return nil 204 | } else if err != nil { 205 | return err 206 | } 207 | 208 | remoteConfig := gitRemote.Config() 209 | if remoteConfig.URLs[0] != remote.URL { 210 | if err := r.DeleteRemote(remote.Name); err != nil { 211 | return err 212 | } 213 | logrus.Infof("Updating remote %s (%s)", remote.Name, remote.URL) 214 | _, err = r.CreateRemote(&gitConfig.RemoteConfig{ 215 | Name: remote.Name, 216 | URLs: []string{remote.URL}, 217 | }) 218 | if err != nil { 219 | return err 220 | } 221 | } 222 | return nil 223 | } 224 | 225 | func headSignedBy(r *git.Repository, publicKeys []string) (signedBy *openpgp.Entity, err error) { 226 | head, _ := r.Head() 227 | if head == nil { 228 | return nil, fmt.Errorf("repository HEAD should not be nil") 229 | } 230 | commit, err := r.CommitObject(head.Hash()) 231 | if err != nil { 232 | return nil, err 233 | } 234 | for _, k := range publicKeys { 235 | entity, err := commit.Verify(k) 236 | if err == nil { 237 | logrus.Debugf("Commit %s signed by %s", head.Hash(), entity.PrimaryIdentity().Name) 238 | return entity, nil 239 | } 240 | } 241 | return nil, fmt.Errorf("commit %s is not signed", head.Hash()) 242 | } 243 | -------------------------------------------------------------------------------- /internal/repository/git_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/ProtonMail/go-crypto/openpgp" 10 | "github.com/go-git/go-git/v5" 11 | "github.com/go-git/go-git/v5/plumbing" 12 | "github.com/go-git/go-git/v5/plumbing/object" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func commitFile(remoteRepository *git.Repository, dir, branch, content string) (commitId string, err error) { 17 | return commitFileAndSign(remoteRepository, dir, branch, content, nil) 18 | } 19 | 20 | func commitFileAndSign(remoteRepository *git.Repository, dir, branch, content string, signKey *openpgp.Entity) (commitId string, err error) { 21 | w, err := remoteRepository.Worktree() 22 | if err != nil { 23 | return 24 | } 25 | _ = w.Checkout(&git.CheckoutOptions{ 26 | Branch: plumbing.NewBranchReferenceName(branch), 27 | Force: true, 28 | }) 29 | 30 | filename := filepath.Join(dir, content) 31 | err = os.WriteFile(filename, []byte(content), 0644) 32 | if err != nil { 33 | return 34 | } 35 | _, err = w.Add(content) 36 | if err != nil { 37 | return 38 | } 39 | hash, err := w.Commit(content, &git.CommitOptions{ 40 | Author: &object.Signature{ 41 | Name: "John Doe", 42 | Email: "john@doe.org", 43 | When: time.Unix(0, 0), 44 | }, 45 | SignKey: signKey, 46 | }) 47 | if err != nil { 48 | return 49 | } 50 | return hash.String(), nil 51 | } 52 | 53 | func initRemoteRepostiory(dir string, initTesting bool) (remoteRepository *git.Repository, err error) { 54 | remoteRepository, err = git.PlainInit(dir, false) 55 | if err != nil { 56 | return 57 | } 58 | 59 | _, err = commitFile(remoteRepository, dir, "main", "file-1") 60 | if err != nil { 61 | return 62 | } 63 | _, err = commitFile(remoteRepository, dir, "main", "file-2") 64 | if err != nil { 65 | return 66 | } 67 | _, err = commitFile(remoteRepository, dir, "main", "file-3") 68 | if err != nil { 69 | return 70 | } 71 | 72 | headRef, err := remoteRepository.Head() 73 | if err != nil { 74 | return 75 | } 76 | ref := plumbing.NewHashReference("refs/heads/main", headRef.Hash()) 77 | err = remoteRepository.Storer.SetReference(ref) 78 | if err != nil { 79 | return 80 | } 81 | if initTesting { 82 | ref = plumbing.NewHashReference("refs/heads/testing", headRef.Hash()) 83 | err = remoteRepository.Storer.SetReference(ref) 84 | if err != nil { 85 | return 86 | } 87 | } 88 | return 89 | } 90 | 91 | func HeadCommitId(r *git.Repository) string { 92 | ref, err := r.Head() 93 | if err != nil { 94 | return "" 95 | } 96 | return ref.Hash().String() 97 | } 98 | 99 | func TestIsAncestor(t *testing.T) { 100 | remoteRepositoryDir := t.TempDir() 101 | repository, err := initRemoteRepostiory(remoteRepositoryDir, true) 102 | assert.Nil(t, err) 103 | 104 | iter, err := repository.Log(&git.LogOptions{}) 105 | assert.Nil(t, err) 106 | 107 | commits := make([]object.Commit, 3) 108 | idx := 0 109 | _ = iter.ForEach(func(commit *object.Commit) error { 110 | commits[idx] = *commit 111 | idx += 1 112 | return nil 113 | }) 114 | 115 | ret, _ := isAncestor(repository, commits[1].Hash, commits[0].Hash) 116 | assert.True(t, ret) 117 | 118 | ret, _ = isAncestor(repository, commits[0].Hash, commits[1].Hash) 119 | assert.False(t, ret) 120 | 121 | ret, _ = isAncestor(repository, commits[0].Hash, commits[0].Hash) 122 | assert.False(t, ret) 123 | 124 | ret, _ = isAncestor(repository, commits[2].Hash, commits[0].Hash) 125 | assert.True(t, ret) 126 | 127 | //time.Sleep(100*time.Second) 128 | } 129 | 130 | func TestHeadSignedBy(t *testing.T) { 131 | dir := t.TempDir() 132 | remoteRepository, _ := git.PlainInit(dir, false) 133 | 134 | r, _ := os.Open("./test.private") 135 | entityList, _ := openpgp.ReadArmoredKeyRing(r) 136 | _, _ = commitFileAndSign(remoteRepository, dir, "main", "file-1", entityList[0]) 137 | 138 | failPublic, _ := os.ReadFile("./fail.public") 139 | testPublic, _ := os.ReadFile("./test.public") 140 | signedBy, err := headSignedBy(remoteRepository, []string{string(failPublic), string(testPublic)}) 141 | assert.Nil(t, err) 142 | assert.Equal(t, "test ", signedBy.PrimaryIdentity().Name) 143 | 144 | signedBy, err = headSignedBy(remoteRepository, []string{string(failPublic)}) 145 | assert.ErrorContains(t, err, "is not signed") 146 | assert.Nil(t, signedBy) 147 | 148 | _, _ = commitFileAndSign(remoteRepository, dir, "main", "file-2", nil) 149 | signedBy, err = headSignedBy(remoteRepository, []string{string(failPublic), string(testPublic)}) 150 | assert.ErrorContains(t, err, "is not signed") 151 | assert.Nil(t, signedBy) 152 | 153 | } 154 | -------------------------------------------------------------------------------- /internal/repository/invalid.public: -------------------------------------------------------------------------------- 1 | Not a valid armored GPG pub key -------------------------------------------------------------------------------- /internal/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "slices" 9 | "time" 10 | 11 | "github.com/ProtonMail/go-crypto/openpgp" 12 | "github.com/go-git/go-git/v5" 13 | "github.com/go-git/go-git/v5/plumbing" 14 | "github.com/nlewo/comin/internal/prometheus" 15 | "github.com/nlewo/comin/internal/types" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | type repository struct { 20 | Repository *git.Repository 21 | GitConfig types.GitConfig 22 | RepositoryStatus RepositoryStatus 23 | prometheus prometheus.Prometheus 24 | gpgPubliKeys []string 25 | } 26 | 27 | type Repository interface { 28 | FetchAndUpdate(ctx context.Context, remoteNames []string) (rsCh chan RepositoryStatus) 29 | // GetRepositoryStatus is currently not thread safe and is only used to initialize the fetcher 30 | GetRepositoryStatus() RepositoryStatus 31 | } 32 | 33 | // repositoryStatus is the last saved repositoryStatus 34 | func New(config types.GitConfig, mainCommitId string, prometheus prometheus.Prometheus) (r *repository, err error) { 35 | gpgPublicKeys := make([]string, len(config.GpgPublicKeyPaths)) 36 | for i, path := range config.GpgPublicKeyPaths { 37 | k, err := os.ReadFile(path) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to open the GPG public key file %s: %w", path, err) 40 | } 41 | _, err = openpgp.ReadArmoredKeyRing(bytes.NewReader(k)) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to read the GPG public key %s: %w", path, err) 44 | } 45 | gpgPublicKeys[i] = string(k) 46 | } 47 | 48 | r = &repository{ 49 | prometheus: prometheus, 50 | gpgPubliKeys: gpgPublicKeys, 51 | } 52 | 53 | r.GitConfig = config 54 | r.Repository, err = repositoryOpen(config) 55 | if err != nil { 56 | return 57 | } 58 | err = manageRemotes(r.Repository, config.Remotes) 59 | if err != nil { 60 | return 61 | } 62 | r.RepositoryStatus = NewRepositoryStatus(config, mainCommitId) 63 | 64 | return 65 | } 66 | 67 | func (r *repository) GetRepositoryStatus() RepositoryStatus { 68 | return r.RepositoryStatus 69 | } 70 | 71 | func (r *repository) FetchAndUpdate(ctx context.Context, remoteNames []string) (rsCh chan RepositoryStatus) { 72 | rsCh = make(chan RepositoryStatus) 73 | go func() { 74 | // FIXME: switch to the FetchContext to clean resource up on timeout 75 | r.Fetch(remoteNames) 76 | _ = r.Update() 77 | rsCh <- r.RepositoryStatus 78 | }() 79 | return rsCh 80 | } 81 | 82 | func (r *repository) Fetch(remoteNames []string) { 83 | var err error 84 | var status string 85 | r.RepositoryStatus.Error = nil 86 | r.RepositoryStatus.ErrorMsg = "" 87 | logrus.Debugf("repository: fetching %s", remoteNames) 88 | for _, remote := range r.GitConfig.Remotes { 89 | repositoryStatusRemote := r.RepositoryStatus.GetRemote(remote.Name) 90 | if !slices.Contains(remoteNames, remote.Name) { 91 | continue 92 | } 93 | if err = fetch(*r, remote); err != nil { 94 | repositoryStatusRemote.FetchErrorMsg = err.Error() 95 | status = "failed" 96 | } else { 97 | repositoryStatusRemote.FetchErrorMsg = "" 98 | repositoryStatusRemote.Fetched = true 99 | status = "succeeded" 100 | } 101 | repositoryStatusRemote.FetchedAt = time.Now().UTC() 102 | r.prometheus.IncFetchCounter(remote.Name, status) 103 | } 104 | } 105 | 106 | func (r *repository) Update() error { 107 | selectedCommitId := "" 108 | 109 | // We first walk on all Main branches in order to get a commit 110 | // from a Main branch. Once found, we could then walk on all 111 | // Testing branches to get a testing commit on top of the Main 112 | // commit. 113 | for _, remote := range r.RepositoryStatus.Remotes { 114 | // If an fetch error occured, we skip this remote 115 | if remote.FetchErrorMsg != "" { 116 | logrus.Debugf( 117 | "The remote %s is skipped because of the fetch error: %s", 118 | remote.Name, 119 | remote.FetchErrorMsg) 120 | continue 121 | } 122 | head, msg, err := getHeadFromRemoteAndBranch( 123 | *r, 124 | remote.Name, 125 | remote.Main.Name, 126 | r.RepositoryStatus.MainCommitId) 127 | if err != nil { 128 | remote.Main.ErrorMsg = err.Error() 129 | logrus.Debugf("Failed to getHeadFromRemoteAndBranch: %s", err) 130 | continue 131 | } else { 132 | remote.Main.ErrorMsg = "" 133 | } 134 | 135 | remote.Main.CommitId = head.String() 136 | remote.Main.CommitMsg = msg 137 | remote.Main.OnTopOf = r.RepositoryStatus.MainCommitId 138 | 139 | if selectedCommitId == "" { 140 | selectedCommitId = head.String() 141 | r.RepositoryStatus.SelectedCommitMsg = msg 142 | r.RepositoryStatus.SelectedBranchName = remote.Main.Name 143 | r.RepositoryStatus.SelectedRemoteName = remote.Name 144 | r.RepositoryStatus.SelectedBranchIsTesting = false 145 | } 146 | if head.String() != r.RepositoryStatus.MainCommitId { 147 | selectedCommitId = head.String() 148 | r.RepositoryStatus.SelectedCommitMsg = msg 149 | r.RepositoryStatus.SelectedBranchName = remote.Main.Name 150 | r.RepositoryStatus.SelectedBranchIsTesting = false 151 | r.RepositoryStatus.SelectedRemoteName = remote.Name 152 | r.RepositoryStatus.MainCommitId = head.String() 153 | r.RepositoryStatus.MainBranchName = remote.Main.Name 154 | r.RepositoryStatus.MainRemoteName = remote.Name 155 | break 156 | } 157 | } 158 | 159 | for _, remote := range r.RepositoryStatus.Remotes { 160 | // If an fetch error occured, we skip this remote 161 | if remote.FetchErrorMsg != "" { 162 | logrus.Debugf( 163 | "The remote %s is skipped because of the fetch error: %s", 164 | remote.Name, 165 | remote.FetchErrorMsg) 166 | continue 167 | } 168 | if remote.Testing.Name == "" { 169 | continue 170 | } 171 | 172 | head, msg, err := getHeadFromRemoteAndBranch( 173 | *r, 174 | remote.Name, 175 | remote.Testing.Name, 176 | r.RepositoryStatus.MainCommitId) 177 | if err != nil { 178 | remote.Testing.ErrorMsg = err.Error() 179 | logrus.Debugf("Failed to getHeadFromRemoteAndBranch: %s", err) 180 | continue 181 | } else { 182 | remote.Testing.ErrorMsg = "" 183 | } 184 | 185 | remote.Testing.CommitId = head.String() 186 | remote.Testing.CommitMsg = msg 187 | remote.Testing.OnTopOf = r.RepositoryStatus.MainCommitId 188 | 189 | if head.String() != selectedCommitId && head.String() != r.RepositoryStatus.MainCommitId { 190 | selectedCommitId = head.String() 191 | r.RepositoryStatus.SelectedCommitMsg = msg 192 | r.RepositoryStatus.SelectedBranchName = remote.Testing.Name 193 | r.RepositoryStatus.SelectedBranchIsTesting = true 194 | r.RepositoryStatus.SelectedRemoteName = remote.Name 195 | break 196 | } 197 | } 198 | 199 | if selectedCommitId != "" { 200 | r.RepositoryStatus.SelectedCommitId = selectedCommitId 201 | } 202 | 203 | if err := hardReset(*r, plumbing.NewHash(selectedCommitId)); err != nil { 204 | r.RepositoryStatus.Error = err 205 | r.RepositoryStatus.ErrorMsg = err.Error() 206 | return err 207 | } 208 | 209 | if len(r.gpgPubliKeys) > 0 { 210 | r.RepositoryStatus.SelectedCommitShouldBeSigned = true 211 | signedBy, err := headSignedBy(r.Repository, r.gpgPubliKeys) 212 | if err != nil { 213 | r.RepositoryStatus.Error = err 214 | r.RepositoryStatus.ErrorMsg = err.Error() 215 | } 216 | if signedBy == nil { 217 | r.RepositoryStatus.SelectedCommitSigned = false 218 | r.RepositoryStatus.SelectedCommitSignedBy = "" 219 | } else { 220 | r.RepositoryStatus.SelectedCommitSigned = true 221 | r.RepositoryStatus.SelectedCommitSignedBy = signedBy.PrimaryIdentity().Name 222 | } 223 | } else { 224 | r.RepositoryStatus.SelectedCommitShouldBeSigned = false 225 | } 226 | return nil 227 | } 228 | -------------------------------------------------------------------------------- /internal/repository/repository_status.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "time" 5 | 6 | deepcopy "github.com/barkimedes/go-deepcopy" 7 | "github.com/nlewo/comin/internal/types" 8 | ) 9 | 10 | type MainBranch struct { 11 | Name string `json:"name,omitempty"` 12 | CommitId string `json:"commit_id,omitempty"` 13 | CommitMsg string `json:"commit_msg,omitempty"` 14 | ErrorMsg string `json:"error_msg,omitempty"` 15 | OnTopOf string `json:"on_top_of,omitempty"` 16 | } 17 | 18 | type TestingBranch struct { 19 | Name string `json:"name,omitempty"` 20 | CommitId string `json:"commit_id,omitempty"` 21 | CommitMsg string `json:"commit_msg,omitempty"` 22 | ErrorMsg string `json:"error_msg,omitempty"` 23 | OnTopOf string `json:"on_top_of,omitempty"` 24 | } 25 | 26 | type Remote struct { 27 | Name string `json:"name,omitempty"` 28 | Url string `json:"url,omitempty"` 29 | FetchErrorMsg string `json:"fetch_error_msg,omitempty"` 30 | Main *MainBranch `json:"main,omitempty"` 31 | Testing *TestingBranch `json:"testing,omitempty"` 32 | FetchedAt time.Time `json:"fetched_at,omitempty"` 33 | Fetched bool `json:"fetched,omitempty"` 34 | } 35 | 36 | type RepositoryStatus struct { 37 | // This is the deployed Main commit ID. It is used to ensure 38 | // fast forward 39 | SelectedCommitId string `json:"selected_commit_id"` 40 | SelectedCommitMsg string `json:"selected_commit_msg"` 41 | SelectedRemoteName string `json:"selected_remote_name"` 42 | SelectedBranchName string `json:"selected_branch_name"` 43 | SelectedBranchIsTesting bool `json:"selected_branch_is_testing"` 44 | SelectedCommitSigned bool `json:"selected_commit_signed"` 45 | SelectedCommitSignedBy string `json:"selected_commit_signed_by"` 46 | // True if public keys were available when the commit has been checked out 47 | SelectedCommitShouldBeSigned bool `json:"selected_commit_should_be_signed"` 48 | MainCommitId string `json:"main_commit_id"` 49 | MainRemoteName string `json:"main_remote_name"` 50 | MainBranchName string `json:"main_branch_name"` 51 | Remotes []*Remote `json:"remotes"` 52 | Error error `json:"-"` 53 | ErrorMsg string `json:"error_msg"` 54 | } 55 | 56 | func NewRepositoryStatus(config types.GitConfig, mainCommitId string) RepositoryStatus { 57 | r := RepositoryStatus{ 58 | MainCommitId: mainCommitId, 59 | } 60 | r.Remotes = make([]*Remote, len(config.Remotes)) 61 | for i, remote := range config.Remotes { 62 | r.Remotes[i] = &Remote{ 63 | Name: remote.Name, 64 | 65 | Url: remote.URL, 66 | Main: &MainBranch{ 67 | Name: remote.Branches.Main.Name, 68 | }, 69 | Testing: &TestingBranch{ 70 | Name: remote.Branches.Testing.Name, 71 | }, 72 | } 73 | } 74 | return r 75 | } 76 | 77 | func (r RepositoryStatus) IsTesting() bool { 78 | return r.SelectedBranchIsTesting 79 | } 80 | 81 | func (r RepositoryStatus) GetRemote(remoteName string) *Remote { 82 | for _, remote := range r.Remotes { 83 | if remote.Name == remoteName { 84 | return remote 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | func (r RepositoryStatus) Copy() RepositoryStatus { 91 | rs, err := deepcopy.Anything(r) 92 | if err != nil { 93 | return RepositoryStatus{} 94 | } 95 | return rs.(RepositoryStatus) 96 | } 97 | -------------------------------------------------------------------------------- /internal/repository/repository_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/ProtonMail/go-crypto/openpgp" 8 | "github.com/go-git/go-git/v5/plumbing" 9 | "github.com/nlewo/comin/internal/prometheus" 10 | "github.com/nlewo/comin/internal/types" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNew(t *testing.T) { 15 | var err error 16 | r1Dir := t.TempDir() 17 | cominRepositoryDir := t.TempDir() 18 | _, _ = initRemoteRepostiory(r1Dir, true) 19 | gitConfig := types.GitConfig{ 20 | Path: cominRepositoryDir, 21 | Remotes: []types.Remote{ 22 | { 23 | Name: "r1", 24 | URL: r1Dir, 25 | Branches: types.Branches{ 26 | Main: types.Branch{ 27 | Name: "main", 28 | }, 29 | Testing: types.Branch{ 30 | Name: "testing", 31 | }, 32 | }, 33 | Timeout: 30, 34 | }, 35 | }, 36 | } 37 | r, err := New(gitConfig, "", prometheus.New()) 38 | assert.Nil(t, err) 39 | assert.Equal(t, "r1", r.RepositoryStatus.Remotes[0].Name) 40 | } 41 | 42 | func TestNewGpg(t *testing.T) { 43 | gitConfig := types.GitConfig{ 44 | GpgPublicKeyPaths: []string{"./fail.public", "./test.public"}, 45 | } 46 | r, err := New(gitConfig, "", prometheus.New()) 47 | assert.Nil(t, err) 48 | assert.Equal(t, 2, len(r.gpgPubliKeys)) 49 | 50 | gitConfig = types.GitConfig{ 51 | GpgPublicKeyPaths: []string{"./fail.public", "./test.public", "./invalid.public"}, 52 | } 53 | _, err = New(gitConfig, "", prometheus.New()) 54 | assert.ErrorContains(t, err, "failed to read the GPG public key") 55 | } 56 | 57 | func TestPreferMain(t *testing.T) { 58 | var err error 59 | r1Dir := t.TempDir() 60 | cominRepositoryDir := t.TempDir() 61 | r1, _ := initRemoteRepostiory(r1Dir, true) 62 | cMain := HeadCommitId(r1) 63 | gitConfig := types.GitConfig{ 64 | Path: cominRepositoryDir, 65 | Remotes: []types.Remote{ 66 | { 67 | Name: "r1", 68 | URL: r1Dir, 69 | Branches: types.Branches{ 70 | Main: types.Branch{ 71 | Name: "main", 72 | }, 73 | Testing: types.Branch{ 74 | Name: "testing", 75 | }, 76 | }, 77 | Timeout: 30, 78 | }, 79 | }, 80 | } 81 | r, err := New(gitConfig, "", prometheus.New()) 82 | assert.Nil(t, err) 83 | // r1/main: c1 - c2 - *c3 84 | // r1/testing: c1 - c2 - c3 85 | r.Fetch([]string{"r1"}) 86 | err = r.Update() 87 | assert.Nil(t, err) 88 | assert.Equal(t, cMain, r.RepositoryStatus.SelectedCommitId) 89 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 90 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 91 | 92 | // r1/main: c1 - c2 - c3 93 | // r1/testing: c1 - c2 - c3 - *c4 94 | c4, _ := commitFile(r1, r1Dir, "testing", "file-4") 95 | r.Fetch([]string{"r1"}) 96 | err = r.Update() 97 | assert.Nil(t, err) 98 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 99 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 100 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 101 | 102 | // r1/main: c1 - c2 - c3 - *c4 103 | // r1/testing: c1 - c2 - c3 - c4 104 | c4, _ = commitFile(r1, r1Dir, "main", "file-4") 105 | r.Fetch([]string{"r1"}) 106 | err = r.Update() 107 | assert.Nil(t, err) 108 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 109 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 110 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 111 | } 112 | 113 | func TestMainCommitId(t *testing.T) { 114 | r1Dir := t.TempDir() 115 | cominRepositoryDir := t.TempDir() 116 | r1, _ := initRemoteRepostiory(r1Dir, true) 117 | cMain := HeadCommitId(r1) 118 | gitConfig := types.GitConfig{ 119 | Path: cominRepositoryDir, 120 | Remotes: []types.Remote{ 121 | types.Remote{ 122 | Name: "r1", 123 | URL: r1Dir, 124 | Branches: types.Branches{ 125 | Main: types.Branch{ 126 | Name: "main", 127 | }, 128 | Testing: types.Branch{ 129 | Name: "testing", 130 | }, 131 | }, 132 | Timeout: 30, 133 | }, 134 | }, 135 | } 136 | r, _ := New(gitConfig, cMain, prometheus.New()) 137 | 138 | // r1/main: c1 - c2 - c3 - c4 139 | // r1/testing: c1 - c2 - c3 - c4 - c5 140 | c4, _ := commitFile(r1, r1Dir, "main", "file-4") 141 | _, _ = commitFile(r1, r1Dir, "testing", "file-4") 142 | c5, _ := commitFile(r1, r1Dir, "testing", "file-5") 143 | r.Fetch([]string{"r1"}) 144 | _ = r.Update() 145 | assert.Equal(t, c4, r.RepositoryStatus.MainCommitId) 146 | assert.Equal(t, c5, r.RepositoryStatus.SelectedCommitId) 147 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 148 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 149 | } 150 | 151 | func TestContinueIfHardReset(t *testing.T) { 152 | r1Dir := t.TempDir() 153 | r2Dir := t.TempDir() 154 | cominRepositoryDir := t.TempDir() 155 | _, _ = initRemoteRepostiory(r1Dir, true) 156 | r2, _ := initRemoteRepostiory(r2Dir, true) 157 | cMain := HeadCommitId(r2) 158 | gitConfig := types.GitConfig{ 159 | Path: cominRepositoryDir, 160 | Remotes: []types.Remote{ 161 | types.Remote{ 162 | Name: "r1", 163 | URL: r1Dir, 164 | Branches: types.Branches{ 165 | Main: types.Branch{ 166 | Name: "main", 167 | }, 168 | Testing: types.Branch{ 169 | Name: "testing", 170 | }, 171 | }, 172 | Timeout: 30, 173 | }, 174 | types.Remote{ 175 | Name: "r2", 176 | URL: r2Dir, 177 | Branches: types.Branches{ 178 | Main: types.Branch{ 179 | Name: "main", 180 | }, 181 | Testing: types.Branch{ 182 | Name: "testing", 183 | }, 184 | }, 185 | Timeout: 30, 186 | }, 187 | }, 188 | } 189 | r, _ := New(gitConfig, cMain, prometheus.New()) 190 | 191 | r.Fetch([]string{"r1", "r2"}) 192 | _ = r.Update() 193 | 194 | // r1/main: c1 - c2 - ^c3 195 | // r1/testing: c1 - c2 - c3 196 | // r2/main: c1 - c2 - c3 197 | // r2/testing: c1 - c2 - c3 - *c4 198 | c4, _ := commitFile(r2, r2Dir, "testing", "file-4") 199 | r.Fetch([]string{"r1", "r2"}) 200 | _ = r.Update() 201 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 202 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 203 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 204 | 205 | // r1/main: c1 - c2 - c3 206 | // r1/testing: c1 - c2 - c3 207 | // r2/main: c1 - c2 - c3 - *c4 208 | // r2/testing: c1 - c2 - c3 - ^c4 209 | c4, _ = commitFile(r2, r2Dir, "main", "file-4") 210 | r.Fetch([]string{"r1", "r2"}) 211 | _ = r.Update() 212 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 213 | assert.Equal(t, "main", r.RepositoryStatus.MainBranchName) 214 | assert.Equal(t, "r2", r.RepositoryStatus.MainRemoteName) 215 | } 216 | 217 | func TestMultipleRemote(t *testing.T) { 218 | var err error 219 | r1Dir := t.TempDir() 220 | r2Dir := t.TempDir() 221 | cominRepositoryDir := t.TempDir() 222 | r1, _ := initRemoteRepostiory(r1Dir, true) 223 | r2, err := initRemoteRepostiory(r2Dir, true) 224 | assert.Nil(t, err) 225 | 226 | gitConfig := types.GitConfig{ 227 | Path: cominRepositoryDir, 228 | Remotes: []types.Remote{ 229 | types.Remote{ 230 | Name: "r1", 231 | URL: r1Dir, 232 | Branches: types.Branches{ 233 | Main: types.Branch{ 234 | Name: "main", 235 | }, 236 | Testing: types.Branch{ 237 | Name: "testing", 238 | }, 239 | }, 240 | Timeout: 30, 241 | }, 242 | types.Remote{ 243 | Name: "r2", 244 | URL: r2Dir, 245 | Branches: types.Branches{ 246 | Main: types.Branch{ 247 | Name: "main", 248 | }, 249 | Testing: types.Branch{ 250 | Name: "testing", 251 | }, 252 | }, 253 | Timeout: 30, 254 | }, 255 | }, 256 | } 257 | r, err := New(gitConfig, "", prometheus.New()) 258 | assert.Nil(t, err) 259 | // r1/main: c1 - c2 - *c3 260 | // r2/main: c1 - c2 - c3 261 | r.Fetch([]string{"r1", "r2"}) 262 | _ = r.Update() 263 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 264 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 265 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 266 | 267 | // r1/main: c1 - c2 - c3 - *c4 268 | // r2/main: c1 - c2 - c3 269 | newCommitId, err := commitFile(r1, r1Dir, "main", "file-4") 270 | assert.Nil(t, err) 271 | r.Fetch([]string{"r1", "r2"}) 272 | _ = r.Update() 273 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 274 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 275 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 276 | 277 | // r1/main: c1 - c2 - c3 - c4 278 | // r2/main: c1 - c2 - c3 - c4 - *c5 279 | _, _ = commitFile(r2, r2Dir, "main", "file-4") 280 | newCommitId, err = commitFile(r2, r2Dir, "main", "file-5") 281 | assert.Nil(t, err) 282 | r.Fetch([]string{"r1", "r2"}) 283 | _ = r.Update() 284 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 285 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 286 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 287 | 288 | // r1/main: c1 - c2 - c3 - c4 - *c5 289 | // r2/main: c1 - c2 - c3 - c4 - c5 290 | newCommitId, err = commitFile(r1, r1Dir, "main", "file-5") 291 | assert.Nil(t, err) 292 | r.Fetch([]string{"r1", "r2"}) 293 | _ = r.Update() 294 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 295 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 296 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 297 | 298 | // r1/main: c1 - c2 - c3 - c4 - c5 - c6 299 | // r2/main: c1 - c2 - c3 - c4 - c5 - c6 300 | // r2/testing: c1 - c2 - c3 - c4 - c5 - c6 - *c7 301 | c6, _ := commitFile(r1, r1Dir, "main", "file-6") 302 | _, _ = commitFile(r2, r2Dir, "main", "file-6") 303 | _, _ = commitFile(r2, r2Dir, "testing", "file-4") 304 | _, _ = commitFile(r2, r2Dir, "testing", "file-5") 305 | _, _ = commitFile(r2, r2Dir, "testing", "file-6") 306 | c7, _ := commitFile(r2, r2Dir, "testing", "file-7") 307 | r.Fetch([]string{"r1", "r2"}) 308 | _ = r.Update() 309 | assert.Equal(t, c6, r.RepositoryStatus.MainCommitId) 310 | assert.Equal(t, c7, r.RepositoryStatus.SelectedCommitId) 311 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 312 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 313 | 314 | // r1/main: c1 - c2 - c3 - c4 - c5 - c6 315 | // r2/main: c1 - c2 - c3 - c4 - c5 - c6 316 | // r2/testing: c1 - c2 - c3 - c4 - c5 - c6 - *c7 317 | r.Fetch([]string{"r1", "r2"}) 318 | _ = r.Update() 319 | assert.Equal(t, c7, r.RepositoryStatus.SelectedCommitId) 320 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 321 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 322 | 323 | // TODO we should return the main commit ID in order to store it in the state 324 | // r1/main: c1 - c2 - c3 - c4 - c5 - c6 - *c8 325 | // r2/main: c1 - c2 - c3 - c4 - c5 - c6 326 | // r2/testing: c1 - c2 - c3 - c4 - c5 - c6 - c7 327 | c8, _ := commitFile(r1, r1Dir, "main", "file-8") 328 | r.Fetch([]string{"r1", "r2"}) 329 | _ = r.Update() 330 | assert.Equal(t, c8, r.RepositoryStatus.SelectedCommitId) 331 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 332 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 333 | 334 | // Only fetch r2 remote 335 | // r1/main: c1 - c2 - c3 - c4 - c5 - c6 - *c8 - c9 336 | // r2/main: c1 - c2 - c3 - c4 - c5 - c6 337 | // r2/testing: c1 - c2 - c3 - c4 - c5 - c6 - c7 338 | c9, _ := commitFile(r1, r1Dir, "main", "file-9") 339 | r.Fetch([]string{"r2"}) 340 | _ = r.Update() 341 | assert.Equal(t, c8, r.RepositoryStatus.SelectedCommitId) 342 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 343 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 344 | 345 | assert.Equal(t, "r1", r.RepositoryStatus.Remotes[0].Name) 346 | assert.Equal(t, "r2", r.RepositoryStatus.Remotes[1].Name) 347 | 348 | // Fetch the r1 remote 349 | // r1/main: c1 - c2 - c3 - c4 - c5 - c6 - c8 - *c9 350 | // r2/main: c1 - c2 - c3 - c4 - c5 - c6 351 | // r2/testing: c1 - c2 - c3 - c4 - c5 - c6 - c7 352 | r.Fetch([]string{"r1"}) 353 | _ = r.Update() 354 | assert.Equal(t, c9, r.RepositoryStatus.SelectedCommitId) 355 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 356 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 357 | } 358 | 359 | func TestTestingSwitch(t *testing.T) { 360 | r1Dir := t.TempDir() 361 | r2Dir := t.TempDir() 362 | cominRepositoryDir := t.TempDir() 363 | _, _ = initRemoteRepostiory(r1Dir, true) 364 | r2, _ := initRemoteRepostiory(r2Dir, true) 365 | cMain := HeadCommitId(r2) 366 | gitConfig := types.GitConfig{ 367 | Path: cominRepositoryDir, 368 | Remotes: []types.Remote{ 369 | types.Remote{ 370 | Name: "r1", 371 | URL: r1Dir, 372 | Branches: types.Branches{ 373 | Main: types.Branch{ 374 | Name: "main", 375 | }, 376 | Testing: types.Branch{ 377 | Name: "testing", 378 | }, 379 | }, 380 | Timeout: 30, 381 | }, 382 | types.Remote{ 383 | Name: "r2", 384 | URL: r2Dir, 385 | Branches: types.Branches{ 386 | Main: types.Branch{ 387 | Name: "main", 388 | }, 389 | Testing: types.Branch{ 390 | Name: "testing", 391 | }, 392 | }, 393 | Timeout: 30, 394 | }, 395 | }, 396 | } 397 | r, _ := New(gitConfig, "", prometheus.New()) 398 | 399 | // r1/main: c1 - c2 - *c3 400 | // r1/testing: c1 - c2 - c3 401 | // r2/main: c1 - c2 - c3 402 | // r2/testing: c1 - c2 - c3 403 | r.Fetch([]string{"r1", "r2"}) 404 | _ = r.Update() 405 | assert.Equal(t, cMain, r.RepositoryStatus.SelectedCommitId) 406 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 407 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 408 | 409 | // r1/main: c1 - c2 - c3 410 | // r1/testing: c1 - c2 - c3 411 | // r2/main: c1 - c2 - c3 412 | // r2/testing: c1 - c2 - c3 - *c4 413 | c4, _ := commitFile(r2, r2Dir, "testing", "file-4") 414 | r.Fetch([]string{"r1", "r2"}) 415 | _ = r.Update() 416 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 417 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 418 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 419 | 420 | // r1/main: c1 - c2 - c3 421 | // r1/testing: c1 - c2 - c3 422 | // r2/main: c1 - c2 - c3 423 | // r2/testing: c1 - c2 - c3 - *c4 424 | r.Fetch([]string{"r1", "r2"}) 425 | _ = r.Update() 426 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 427 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 428 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 429 | 430 | // r1/main: c1 - c2 - c3 431 | // r1/testing: c1 - c2 - c3 432 | // r2/main: c1 - c2 - c3 - *c4 433 | // r2/testing: c1 - c2 - c3 - c4 434 | _, _ = commitFile(r2, r2Dir, "main", "file-4") 435 | r.Fetch([]string{"r1", "r2"}) 436 | _ = r.Update() 437 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 438 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 439 | assert.Equal(t, "r2", r.RepositoryStatus.SelectedRemoteName) 440 | } 441 | 442 | func TestWithoutTesting(t *testing.T) { 443 | var err error 444 | r1Dir := t.TempDir() 445 | cominRepositoryDir := t.TempDir() 446 | _, err = initRemoteRepostiory(r1Dir, false) 447 | assert.Nil(t, err) 448 | gitConfig := types.GitConfig{ 449 | Path: cominRepositoryDir, 450 | Remotes: []types.Remote{ 451 | types.Remote{ 452 | Name: "r1", 453 | URL: r1Dir, 454 | Branches: types.Branches{ 455 | Main: types.Branch{ 456 | Name: "main", 457 | }, 458 | Testing: types.Branch{ 459 | Name: "testing", 460 | }, 461 | }, 462 | Timeout: 30, 463 | }, 464 | }, 465 | } 466 | r, _ := New(gitConfig, "", prometheus.New()) 467 | 468 | r.Fetch([]string{"r1"}) 469 | _ = r.Update() 470 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 471 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 472 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 473 | } 474 | 475 | func TestRepositoryUpdateMain(t *testing.T) { 476 | remoteRepositoryDir := t.TempDir() 477 | cominRepositoryDir := t.TempDir() 478 | remoteRepository, err := initRemoteRepostiory(remoteRepositoryDir, true) 479 | assert.Nil(t, err) 480 | 481 | gitConfig := types.GitConfig{ 482 | Path: cominRepositoryDir, 483 | Remotes: []types.Remote{ 484 | types.Remote{ 485 | Name: "origin", 486 | URL: remoteRepositoryDir, 487 | Branches: types.Branches{ 488 | Main: types.Branch{ 489 | Name: "main", 490 | }, 491 | Testing: types.Branch{ 492 | Name: "testing", 493 | }, 494 | }, 495 | Timeout: 30, 496 | }, 497 | }, 498 | } 499 | r, _ := New(gitConfig, "", prometheus.New()) 500 | 501 | // The remote repository is initially checkouted 502 | r.Fetch([]string{"origin"}) 503 | _ = r.Update() 504 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 505 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 506 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 507 | 508 | // Without any new remote commits, the local repository is not updated 509 | r.Fetch([]string{"origin"}) 510 | _ = r.Update() 511 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 512 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 513 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 514 | 515 | // A new commit is pushed to the remote repository: the local 516 | // repository is updated 517 | newCommitId, _ := commitFile(remoteRepository, remoteRepositoryDir, "main", "file-4") 518 | r.Fetch([]string{"origin"}) 519 | _ = r.Update() 520 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 521 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 522 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 523 | 524 | // A commit is pushed to the testing branch which is currently 525 | // behind the main branch: the repository is not updated 526 | _, _ = commitFile(remoteRepository, remoteRepositoryDir, "testing", "file-5") 527 | r.Fetch([]string{"origin"}) 528 | _ = r.Update() 529 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 530 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 531 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 532 | } 533 | 534 | func TestRepositoryUpdateHardResetMain(t *testing.T) { 535 | remoteRepositoryDir := t.TempDir() 536 | cominRepositoryDir := t.TempDir() 537 | remoteRepository, err := initRemoteRepostiory(remoteRepositoryDir, true) 538 | assert.Nil(t, err) 539 | 540 | gitConfig := types.GitConfig{ 541 | Path: cominRepositoryDir, 542 | Remotes: []types.Remote{ 543 | types.Remote{ 544 | Name: "origin", 545 | URL: remoteRepositoryDir, 546 | Branches: types.Branches{ 547 | Main: types.Branch{ 548 | Name: "main", 549 | }, 550 | Testing: types.Branch{ 551 | Name: "testing", 552 | }, 553 | }, 554 | Timeout: 30, 555 | }, 556 | }, 557 | } 558 | r, _ := New(gitConfig, "", prometheus.New()) 559 | 560 | // The remote repository is initially checkouted 561 | r.Fetch([]string{"origin"}) 562 | _ = r.Update() 563 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 564 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 565 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 566 | 567 | // Two commits are added to get a previous commit hash in 568 | // order to reset it. 569 | previousHash, _ := commitFile(remoteRepository, remoteRepositoryDir, "main", "file-4") 570 | newCommitId, _ := commitFile(remoteRepository, remoteRepositoryDir, "main", "file-5") 571 | 572 | r.Fetch([]string{"origin"}) 573 | _ = r.Update() 574 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 575 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 576 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 577 | 578 | // The last commit of the main branch is removed. 579 | // FIXME: ideally we should provide a message saying no valid main branch has been found 580 | ref := plumbing.NewHashReference("refs/heads/main", plumbing.NewHash(previousHash)) 581 | err = remoteRepository.Storer.SetReference(ref) 582 | if err != nil { 583 | return 584 | } 585 | r.Fetch([]string{"origin"}) 586 | _ = r.Update() 587 | assert.Equal(t, newCommitId, r.RepositoryStatus.SelectedCommitId) 588 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 589 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 590 | assert.Contains(t, r.RepositoryStatus.Remotes[0].Main.ErrorMsg, "this branch has been hard reset") 591 | } 592 | 593 | func TestRepositoryUpdateTesting(t *testing.T) { 594 | remoteRepositoryDir := t.TempDir() 595 | cominRepositoryDir := t.TempDir() 596 | remoteRepository, err := initRemoteRepostiory(remoteRepositoryDir, true) 597 | assert.Nil(t, err) 598 | 599 | gitConfig := types.GitConfig{ 600 | Path: cominRepositoryDir, 601 | Remotes: []types.Remote{ 602 | types.Remote{ 603 | Name: "origin", 604 | URL: remoteRepositoryDir, 605 | Branches: types.Branches{ 606 | Main: types.Branch{ 607 | Name: "main", 608 | }, 609 | Testing: types.Branch{ 610 | Name: "testing", 611 | }, 612 | }, 613 | Timeout: 30, 614 | }, 615 | }, 616 | } 617 | r, _ := New(gitConfig, "", prometheus.New()) 618 | 619 | // The remote repository is initially checkouted on main 620 | r.Fetch([]string{"origin"}) 621 | _ = r.Update() 622 | assert.Equal(t, HeadCommitId(r.Repository), r.RepositoryStatus.SelectedCommitId) 623 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 624 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 625 | 626 | // A new commit is pushed to the testing branch remote repository: the local 627 | // repository is updated 628 | commitId4, _ := commitFile(remoteRepository, remoteRepositoryDir, "testing", "file-4") 629 | r.Fetch([]string{"origin"}) 630 | _ = r.Update() 631 | assert.Equal(t, commitId4, r.RepositoryStatus.SelectedCommitId) 632 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 633 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 634 | 635 | // A new commit is pushed to the testing branch remote repository: the local 636 | // repository is updated 637 | commitId5, _ := commitFile(remoteRepository, remoteRepositoryDir, "testing", "file-5") 638 | r.Fetch([]string{"origin"}) 639 | _ = r.Update() 640 | assert.Equal(t, commitId5, r.RepositoryStatus.SelectedCommitId) 641 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 642 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 643 | 644 | // The main branch is rebased on top of testing: we switch 645 | // back the the main branch 646 | testingHeadRef, _ := remoteRepository.Reference( 647 | plumbing.ReferenceName("refs/heads/testing"), 648 | true) 649 | ref := plumbing.NewHashReference("refs/heads/main", testingHeadRef.Hash()) 650 | err = remoteRepository.Storer.SetReference(ref) 651 | if err != nil { 652 | return 653 | } 654 | r.Fetch([]string{"origin"}) 655 | _ = r.Update() 656 | assert.Equal(t, commitId5, r.RepositoryStatus.SelectedCommitId) 657 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 658 | assert.Equal(t, "origin", r.RepositoryStatus.SelectedRemoteName) 659 | } 660 | 661 | func TestTestingHardReset(t *testing.T) { 662 | var err error 663 | r1Dir := t.TempDir() 664 | cominRepositoryDir := t.TempDir() 665 | r1, _ := initRemoteRepostiory(r1Dir, true) 666 | cMain := HeadCommitId(r1) 667 | gitConfig := types.GitConfig{ 668 | Path: cominRepositoryDir, 669 | Remotes: []types.Remote{ 670 | { 671 | Name: "r1", 672 | URL: r1Dir, 673 | Branches: types.Branches{ 674 | Main: types.Branch{ 675 | Name: "main", 676 | }, 677 | Testing: types.Branch{ 678 | Name: "testing", 679 | }, 680 | }, 681 | Timeout: 30, 682 | }, 683 | }, 684 | } 685 | r, err := New(gitConfig, "", prometheus.New()) 686 | assert.Nil(t, err) 687 | // r1/main: c1 - c2 - *c3 688 | // r1/testing: c1 - c2 - c3 689 | r.Fetch([]string{"r1"}) 690 | err = r.Update() 691 | assert.Nil(t, err) 692 | assert.Equal(t, cMain, r.RepositoryStatus.SelectedCommitId) 693 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 694 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 695 | 696 | // r1/main: c1 - c2 - c3 697 | // r1/testing: c1 - c2 - c3 - *c4 698 | c4, _ := commitFile(r1, r1Dir, "testing", "file-4") 699 | r.Fetch([]string{"r1"}) 700 | err = r.Update() 701 | assert.Nil(t, err) 702 | assert.Equal(t, c4, r.RepositoryStatus.SelectedCommitId) 703 | assert.Equal(t, "testing", r.RepositoryStatus.SelectedBranchName) 704 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 705 | 706 | // r1/main: c1 - c2 - *c3 707 | // r1/testing: c1 - c2 - c3 708 | ref := plumbing.NewHashReference("refs/heads/testing", plumbing.NewHash(cMain)) 709 | _ = r1.Storer.SetReference(ref) 710 | r.Fetch([]string{"r1"}) 711 | err = r.Update() 712 | assert.Nil(t, err) 713 | assert.Equal(t, cMain, r.RepositoryStatus.SelectedCommitId) 714 | assert.Equal(t, "main", r.RepositoryStatus.SelectedBranchName) 715 | assert.Equal(t, "r1", r.RepositoryStatus.SelectedRemoteName) 716 | } 717 | 718 | func TestUpdateGpg(t *testing.T) { 719 | dir := t.TempDir() 720 | cominRepositoryDir := t.TempDir() 721 | r1, _ := initRemoteRepostiory(dir, true) 722 | 723 | f, _ := os.Open("./test.private") 724 | entityList, _ := openpgp.ReadArmoredKeyRing(f) 725 | entity := entityList[0] 726 | _, _ = commitFileAndSign(r1, dir, "main", "file-1", entity) 727 | cMain := HeadCommitId(r1) 728 | 729 | gitConfig := types.GitConfig{ 730 | Path: cominRepositoryDir, 731 | GpgPublicKeyPaths: []string{"./test.public", "./fail.public"}, 732 | Remotes: []types.Remote{ 733 | { 734 | Name: "r1", 735 | URL: dir, 736 | Branches: types.Branches{ 737 | Main: types.Branch{ 738 | Name: "main", 739 | }, 740 | }, 741 | Timeout: 30, 742 | }, 743 | }, 744 | } 745 | r, err := New(gitConfig, "", prometheus.New()) 746 | assert.Nil(t, err) 747 | r.Fetch([]string{"r1"}) 748 | err = r.Update() 749 | assert.Nil(t, err) 750 | assert.Equal(t, cMain, r.RepositoryStatus.SelectedCommitId) 751 | assert.True(t, r.RepositoryStatus.SelectedCommitSigned) 752 | assert.Equal(t, "test ", r.RepositoryStatus.SelectedCommitSignedBy) 753 | assert.True(t, r.RepositoryStatus.SelectedCommitShouldBeSigned) 754 | 755 | _, _ = commitFile(r1, dir, "main", "file-2") 756 | r.Fetch([]string{"r1"}) 757 | err = r.Update() 758 | assert.Nil(t, err) 759 | assert.Equal(t, HeadCommitId(r1), r.RepositoryStatus.SelectedCommitId) 760 | assert.False(t, r.RepositoryStatus.SelectedCommitSigned) 761 | assert.Equal(t, "", r.RepositoryStatus.SelectedCommitSignedBy) 762 | assert.True(t, r.RepositoryStatus.SelectedCommitShouldBeSigned) 763 | 764 | // No GPG keys available so commits don't need to be signed 765 | gitConfig = types.GitConfig{ 766 | Path: cominRepositoryDir, 767 | Remotes: []types.Remote{ 768 | { 769 | Name: "r1", 770 | URL: dir, 771 | Branches: types.Branches{ 772 | Main: types.Branch{ 773 | Name: "main", 774 | }, 775 | }, 776 | Timeout: 30, 777 | }, 778 | }, 779 | } 780 | r, err = New(gitConfig, "", prometheus.New()) 781 | assert.Nil(t, err) 782 | r.Fetch([]string{"r1"}) 783 | err = r.Update() 784 | assert.Nil(t, err) 785 | assert.False(t, r.RepositoryStatus.SelectedCommitSigned) 786 | assert.Equal(t, "", r.RepositoryStatus.SelectedCommitSignedBy) 787 | assert.False(t, r.RepositoryStatus.SelectedCommitShouldBeSigned) 788 | } 789 | -------------------------------------------------------------------------------- /internal/repository/test.private: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PRIVATE KEY BLOCK----- 2 | 3 | lFgEZ4lpqhYJKwYBBAHaRw8BAQdAhG8F35gOiGpGbTXXLePu9CuX7yRc1wRkSlMV 4 | bEQggncAAQDpEvcmClRNLm2igJ33lzGpv+oz8+SpxK/C/x1n1amRyxJ0tBd0ZXN0 5 | IDx0ZXN0QGNvbWluLnNwYWNlPoiUBBMWCgA8FiEEwp3AlleagwWbiZq2sPzqz40E 6 | VgQFAmeJaaoCGwMFCQWjmoAECwkIBwQVCgkIBRYCAwEAAh4FAheAAAoJELD86s+N 7 | BFYEBjAA/3pcckmyDp37KRTOwLVQYKuQpGIhfyvR34D3P/qCayTDAP4jpiHOJ4VG 8 | s6gF2KoRPdjcCAFAQqA6EpALpDWQE9ljBpxdBGeJaaoSCisGAQQBl1UBBQEBB0De 9 | q04TspciL1YqF1sUG+Nd4yS5oQIhLV23RADZTNxfbQMBCAcAAP9kxG36krkVc+OX 10 | wninZe9ERgnhFl/7Fvrhk6CkClydGBHXiH4EGBYKACYWIQTCncCWV5qDBZuJmraw 11 | /OrPjQRWBAUCZ4lpqgIbDAUJBaOagAAKCRCw/OrPjQRWBOP/AP9xreDJhTW+QlU+ 12 | LoxCwUTzE4yCQgu1FK9ccD1Cf1tBMwD/ZiqPFVqW+WidXa7IsR8zT9Sd0Q/CzfNk 13 | /tvn0pux+QU= 14 | =vPHb 15 | -----END PGP PRIVATE KEY BLOCK----- 16 | -------------------------------------------------------------------------------- /internal/repository/test.public: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mDMEZ4lpqhYJKwYBBAHaRw8BAQdAhG8F35gOiGpGbTXXLePu9CuX7yRc1wRkSlMV 4 | bEQggne0F3Rlc3QgPHRlc3RAY29taW4uc3BhY2U+iJQEExYKADwWIQTCncCWV5qD 5 | BZuJmraw/OrPjQRWBAUCZ4lpqgIbAwUJBaOagAQLCQgHBBUKCQgFFgIDAQACHgUC 6 | F4AACgkQsPzqz40EVgQGMAD/elxySbIOnfspFM7AtVBgq5CkYiF/K9HfgPc/+oJr 7 | JMMA/iOmIc4nhUazqAXYqhE92NwIAUBCoDoSkAukNZAT2WMGuDgEZ4lpqhIKKwYB 8 | BAGXVQEFAQEHQN6rThOylyIvVioXWxQb413jJLmhAiEtXbdEANlM3F9tAwEIB4h+ 9 | BBgWCgAmFiEEwp3AlleagwWbiZq2sPzqz40EVgQFAmeJaaoCGwwFCQWjmoAACgkQ 10 | sPzqz40EVgTj/wD/ca3gyYU1vkJVPi6MQsFE8xOMgkILtRSvXHA9Qn9bQTMA/2Yq 11 | jxValvlonV2uyLEfM0/UndEPws3zZP7b59KbsfkF 12 | =YhvJ 13 | -----END PGP PUBLIC KEY BLOCK----- 14 | -------------------------------------------------------------------------------- /internal/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/go-co-op/gocron/v2" 8 | "github.com/nlewo/comin/internal/fetcher" 9 | "github.com/nlewo/comin/internal/types" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type Scheduler struct { 14 | s gocron.Scheduler 15 | } 16 | 17 | func New() Scheduler { 18 | s, _ := gocron.NewScheduler() 19 | 20 | sched := Scheduler{ 21 | s: s, 22 | } 23 | go sched.s.Start() 24 | return sched 25 | } 26 | 27 | func (s Scheduler) FetchRemotes(fetcher *fetcher.Fetcher, remotes []types.Remote) { 28 | for _, remote := range remotes { 29 | if remote.Poller.Period != 0 { 30 | logrus.Infof("scheduler: starting the period job for the remote '%s' with period %ds", remote.Name, remote.Poller.Period) 31 | _, _ = s.s.NewJob( 32 | gocron.DurationJob( 33 | time.Duration(remote.Poller.Period)*time.Second, 34 | ), 35 | gocron.NewTask( 36 | func() { 37 | logrus.Debugf("scheduler: running task for remote %s", remote.Name) 38 | fetcher.TriggerFetch([]string{remote.Name}) 39 | }, 40 | ), 41 | gocron.WithSingletonMode(gocron.LimitModeReschedule), 42 | gocron.WithName(fmt.Sprintf("fetch-remote-%s", remote.Name)), 43 | ) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | 8 | "github.com/nlewo/comin/internal/deployer" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type Data struct { 13 | Version string `json:"version"` 14 | // Deployments are order from the most recent to older 15 | Deployments []deployer.Deployment `json:"deployments"` 16 | } 17 | 18 | type Store struct { 19 | Data 20 | filename string 21 | capacityMain int 22 | capacityTesting int 23 | } 24 | 25 | func New(filename string, capacityMain, capacityTesting int) Store { 26 | s := Store{ 27 | filename: filename, 28 | capacityMain: capacityMain, 29 | capacityTesting: capacityTesting, 30 | } 31 | s.Deployments = make([]deployer.Deployment, 0) 32 | s.Version = "1" 33 | return s 34 | 35 | } 36 | 37 | func (s *Store) DeploymentInsertAndCommit(dpl deployer.Deployment) (ok bool, evicted deployer.Deployment) { 38 | ok, evicted = s.DeploymentInsert(dpl) 39 | if ok { 40 | logrus.Infof("The deployment %s has been removed from store.json file", evicted.UUID) 41 | } 42 | if err := s.Commit(); err != nil { 43 | logrus.Errorf("Error while commiting the store.json file: %s", err) 44 | return 45 | } 46 | logrus.Infof("The new deployment %s has been commited to store.json file", dpl.UUID) 47 | return 48 | } 49 | 50 | // DeploymentInsert inserts a deployment and return an evicted 51 | // deployment because the capacity has been reached. 52 | func (s *Store) DeploymentInsert(dpl deployer.Deployment) (getsEvicted bool, evicted deployer.Deployment) { 53 | var qty, older int 54 | capacity := s.capacityMain 55 | if dpl.IsTesting() { 56 | capacity = s.capacityTesting 57 | } 58 | for i, d := range s.Deployments { 59 | if dpl.IsTesting() == d.IsTesting() { 60 | older = i 61 | qty += 1 62 | } 63 | } 64 | // If the capacity is reached, we remove the older elements 65 | if qty >= capacity { 66 | evicted = s.Deployments[older] 67 | getsEvicted = true 68 | s.Deployments = append(s.Deployments[:older], s.Deployments[older+1:]...) 69 | } 70 | s.Deployments = append([]deployer.Deployment{dpl}, s.Deployments...) 71 | return 72 | } 73 | 74 | func (s *Store) DeploymentList() []deployer.Deployment { 75 | return s.Deployments 76 | } 77 | 78 | func (s *Store) LastDeployment() (ok bool, d deployer.Deployment) { 79 | if len(s.DeploymentList()) > 1 { 80 | return true, s.DeploymentList()[0] 81 | } 82 | return 83 | } 84 | 85 | func (s *Store) Load() (err error) { 86 | var data Data 87 | content, err := os.ReadFile(s.filename) 88 | if errors.Is(err, os.ErrNotExist) { 89 | return nil 90 | } else if err != nil { 91 | return 92 | } 93 | err = json.Unmarshal(content, &data) 94 | if err != nil { 95 | return 96 | } 97 | // FIXME: we should check the version 98 | s.Deployments = data.Deployments 99 | logrus.Infof("Loaded %d deployments from %s", len(s.Deployments), s.filename) 100 | return 101 | } 102 | 103 | func (s *Store) Commit() (err error) { 104 | content, err := json.Marshal(s) 105 | if err != nil { 106 | return 107 | } 108 | err = os.WriteFile(s.filename, content, 0644) 109 | return 110 | } 111 | -------------------------------------------------------------------------------- /internal/store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nlewo/comin/internal/deployer" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDeploymentCommitAndLoad(t *testing.T) { 11 | tmp := t.TempDir() 12 | filename := tmp + "/state.json" 13 | s := New(filename, 2, 2) 14 | err := s.Commit() 15 | assert.Nil(t, err) 16 | 17 | s1 := New(filename, 2, 2) 18 | err = s1.Load() 19 | assert.Nil(t, err) 20 | assert.Equal(t, 0, len(s.Deployments)) 21 | 22 | s.DeploymentInsert(deployer.Deployment{UUID: "1", Operation: "switch"}) 23 | _ = s.Commit() 24 | assert.Nil(t, err) 25 | 26 | s1 = New(filename, 2, 2) 27 | err = s1.Load() 28 | assert.Nil(t, err) 29 | assert.Equal(t, 1, len(s.Deployments)) 30 | } 31 | 32 | func TestLastDeployment(t *testing.T) { 33 | s := New("", 2, 2) 34 | ok, _ := s.LastDeployment() 35 | assert.False(t, ok) 36 | s.DeploymentInsert(deployer.Deployment{UUID: "1", Operation: "switch"}) 37 | s.DeploymentInsert(deployer.Deployment{UUID: "2", Operation: "switch"}) 38 | ok, last := s.LastDeployment() 39 | assert.True(t, ok) 40 | assert.Equal(t, "2", last.UUID) 41 | } 42 | 43 | func TestDeploymentInsert(t *testing.T) { 44 | s := New("", 2, 2) 45 | var hasEvicted bool 46 | var evicted deployer.Deployment 47 | hasEvicted, _ = s.DeploymentInsert(deployer.Deployment{UUID: "1", Operation: "switch"}) 48 | assert.False(t, hasEvicted) 49 | hasEvicted, _ = s.DeploymentInsert(deployer.Deployment{UUID: "2", Operation: "switch"}) 50 | assert.False(t, hasEvicted) 51 | hasEvicted, evicted = s.DeploymentInsert(deployer.Deployment{UUID: "3", Operation: "switch"}) 52 | assert.True(t, hasEvicted) 53 | assert.Equal(t, "1", evicted.UUID) 54 | expected := []deployer.Deployment{ 55 | {UUID: "3", Operation: "switch"}, 56 | {UUID: "2", Operation: "switch"}, 57 | } 58 | assert.Equal(t, expected, s.DeploymentList()) 59 | 60 | hasEvicted, _ = s.DeploymentInsert(deployer.Deployment{UUID: "4", Operation: "test"}) 61 | assert.False(t, hasEvicted) 62 | hasEvicted, _ = s.DeploymentInsert(deployer.Deployment{UUID: "5", Operation: "test"}) 63 | assert.False(t, hasEvicted) 64 | hasEvicted, evicted = s.DeploymentInsert(deployer.Deployment{UUID: "6", Operation: "test"}) 65 | assert.True(t, hasEvicted) 66 | assert.Equal(t, "4", evicted.UUID) 67 | expected = []deployer.Deployment{ 68 | {UUID: "6", Operation: "test"}, 69 | {UUID: "5", Operation: "test"}, 70 | {UUID: "3", Operation: "switch"}, 71 | {UUID: "2", Operation: "switch"}, 72 | } 73 | assert.Equal(t, expected, s.DeploymentList()) 74 | 75 | hasEvicted, evicted = s.DeploymentInsert(deployer.Deployment{UUID: "7", Operation: "switch"}) 76 | assert.True(t, hasEvicted) 77 | assert.Equal(t, "2", evicted.UUID) 78 | hasEvicted, evicted = s.DeploymentInsert(deployer.Deployment{UUID: "8", Operation: "switch"}) 79 | assert.True(t, hasEvicted) 80 | assert.Equal(t, "3", evicted.UUID) 81 | } 82 | -------------------------------------------------------------------------------- /internal/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Remote struct { 4 | Name string 5 | URL string 6 | Auth Auth 7 | Branches Branches `yaml:"branches"` 8 | Timeout int `yaml:"timeout"` 9 | // The period to poll the remote in second 10 | Poller Poller `yaml:"poller"` 11 | } 12 | 13 | type Poller struct { 14 | Period int `yaml:"period"` 15 | } 16 | 17 | type GitConfig struct { 18 | // The repository Path 19 | Path string 20 | // The directory in the repository 21 | Dir string 22 | Remotes []Remote 23 | GpgPublicKeyPaths []string 24 | } 25 | 26 | type Auth struct { 27 | AccessToken string 28 | AccessTokenPath string `yaml:"access_token_path"` 29 | } 30 | 31 | type Branch struct { 32 | Name string `yaml:"name"` 33 | // TODO: use it 34 | Protected bool `yaml:"protected"` 35 | } 36 | 37 | type Branches struct { 38 | Main Branch `yaml:"main"` 39 | Testing Branch `yaml:"testing"` 40 | } 41 | 42 | type HttpServer struct { 43 | ListenAddress string `yaml:"listen_address"` 44 | Port int `yaml:"port"` 45 | } 46 | 47 | type Configuration struct { 48 | Hostname string `yaml:"hostname"` 49 | StateDir string `yaml:"state_dir"` 50 | StateFilepath string `yaml:"state_filepath"` 51 | FlakeSubdirectory string `yaml:"flake_subdirectory"` 52 | Remotes []Remote `yaml:"remotes"` 53 | ApiServer HttpServer `yaml:"api_server"` 54 | Exporter HttpServer `yaml:"exporter"` 55 | GpgPublicKeyPaths []string `yaml:"gpg_public_key_paths"` 56 | PostDeploymentCommand string `yaml:"post_deployment_command"` 57 | } 58 | -------------------------------------------------------------------------------- /internal/utils/reboot.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // NeedToReboot return true when the current deployed kernel is not 10 | // the booted kernel. Note we should implement something smarter such 11 | // as described in 12 | // https://discourse.nixos.org/t/nixos-needsreboot-determine-if-you-need-to-reboot-your-nixos-machine/40790 13 | func NeedToReboot() (reboot bool) { 14 | current, err := os.Readlink("/run/current-system/kernel") 15 | if err != nil { 16 | logrus.Errorf("Failed to read the symlink /run/current-system/kernel: %s", err) 17 | return 18 | } 19 | booted, err := os.Readlink("/run/booted-system/kernel") 20 | if err != nil { 21 | logrus.Errorf("Failed to read the symlink /run/booted-system/kernel: %s", err) 22 | return 23 | } 24 | if current != booted { 25 | reboot = true 26 | } 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /internal/utils/testing.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nlewo/comin/internal/repository" 7 | ) 8 | 9 | type RepositoryMock struct { 10 | RsCh chan repository.RepositoryStatus 11 | } 12 | 13 | func NewRepositoryMock() (r *RepositoryMock) { 14 | rsCh := make(chan repository.RepositoryStatus, 5) 15 | return &RepositoryMock{ 16 | RsCh: rsCh, 17 | } 18 | } 19 | func (r *RepositoryMock) FetchAndUpdate(ctx context.Context, remoteNames []string) (rsCh chan repository.RepositoryStatus) { 20 | return r.RsCh 21 | } 22 | func (r *RepositoryMock) GetRepositoryStatus() repository.RepositoryStatus { 23 | return repository.RepositoryStatus{} 24 | } 25 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func CominServiceRestart() error { 13 | logrus.Infof("The comin.service unit file changed. Comin systemd service is now restarted...") 14 | logrus.Infof("Restarting the systemd comin.service: 'systemctl restart --no-block comin.service'") 15 | cmd := exec.Command("systemctl", "restart", "--no-block", "comin.service") 16 | cmd.Stdout = os.Stdout 17 | cmd.Stderr = os.Stderr 18 | if err := cmd.Run(); err != nil { 19 | return fmt.Errorf("command 'systemctl restart --no-block comin.service' fails with %s", err) 20 | } 21 | return nil 22 | } 23 | 24 | func FormatCommitMsg(msg string) string { 25 | split := strings.Split(msg, "\n") 26 | formatted := "" 27 | for i, s := range split { 28 | if i == len(split)-1 && s == "" { 29 | continue 30 | } 31 | if i == 0 { 32 | formatted += s 33 | } else { 34 | formatted += "\n " + s 35 | } 36 | } 37 | return formatted 38 | } 39 | 40 | func ReadMachineId() (machineId string, err error) { 41 | machineIdBytes, err := os.ReadFile("/etc/machine-id") 42 | machineId = strings.TrimSuffix(string(machineIdBytes), "\n") 43 | if err != nil { 44 | return "", fmt.Errorf("can not read file '/etc/machine-id': %s", err) 45 | } 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestFormatCommitMsg(t *testing.T) { 9 | var msg, formatted, expected string 10 | msg = `Summary 11 | 12 | Long Body 13 | ` 14 | formatted = FormatCommitMsg(msg) 15 | expected = `Summary 16 | 17 | Long Body` 18 | assert.Equal(t, expected, formatted) 19 | 20 | msg = "Summary" 21 | formatted = FormatCommitMsg(msg) 22 | expected = "Summary" 23 | assert.Equal(t, expected, formatted) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 Antoine Eiche 3 | */ 4 | package main 5 | 6 | import "github.com/nlewo/comin/cmd" 7 | 8 | func main() { 9 | cmd.Execute() 10 | } 11 | -------------------------------------------------------------------------------- /nix/module-options-doc.nix: -------------------------------------------------------------------------------- 1 | pkgs: rec { 2 | optionsDocCommonMark = let 3 | eval = pkgs.lib.evalModules { 4 | modules = [ 5 | (import ./module-options.nix) 6 | { 7 | options = { 8 | _module.args = pkgs.lib.mkOption { 9 | internal = true; 10 | }; 11 | networking.hostName = pkgs.lib.mkOption { 12 | type = pkgs.lib.types.str; 13 | internal = true; 14 | default = "the-machine-hostname"; 15 | }; 16 | }; 17 | } 18 | ]; 19 | }; 20 | optionsDoc = pkgs.nixosOptionsDoc { 21 | inherit (eval) options; 22 | }; 23 | in 24 | pkgs.runCommand "options-doc.md" {} '' 25 | cat ${optionsDoc.optionsCommonMark} >> $out 26 | ''; 27 | optionsDocCommonMarkGenerator = pkgs.writers.writeBashBin "optionsDocCommonMarkGenerator" '' 28 | cp -v ${optionsDocCommonMark} ./docs/generated-module-options.md 29 | chmod u+w ./docs/generated-module-options.md 30 | ''; 31 | checkOptionsDocCommonMark = pkgs.runCommand "check-options-doc.md" {} '' 32 | set +e 33 | ${pkgs.diffutils}/bin/diff -q ${optionsDocCommonMark} ${../docs/generated-module-options.md} 34 | if [[ $? -ne 0 ]] 35 | then 36 | echo "The ./docs/module-options.md file is not up to date." 37 | echo "Run 'nix run .#generate-module-options' to generate it!" 38 | exit 1 39 | fi 40 | echo Files ${optionsDocCommonMark} ${../docs/generated-module-options.md} are identical > $out 41 | ''; 42 | } 43 | -------------------------------------------------------------------------------- /nix/module-options.nix: -------------------------------------------------------------------------------- 1 | { config, pkgs, lib, ... }: { 2 | options = with lib; with types; { 3 | services.comin = { 4 | enable = mkOption { 5 | type = types.bool; 6 | default = false; 7 | description = '' 8 | Whether to run the comin service. 9 | ''; 10 | }; 11 | package = lib.mkPackageOption pkgs "comin" { nullable = true; } // { 12 | defaultText = "pkgs.comin or comin.packages.\${system}.default or null"; 13 | }; 14 | hostname = mkOption { 15 | type = str; 16 | default = config.networking.hostName; 17 | description = '' 18 | The name of the NixOS configuration to evaluate and 19 | deploy. This value is used by comin to evaluate the 20 | flake output 21 | nixosConfigurations."".config.system.build.toplevel 22 | ''; 23 | }; 24 | flakeSubdirectory = mkOption { 25 | type = str; 26 | default = "."; 27 | description = '' 28 | Subdirectory in the repository, containing flake.nix. 29 | ''; 30 | }; 31 | exporter = mkOption { 32 | description = "Options for the Prometheus exporter."; 33 | default = {}; 34 | type = submodule { 35 | options = { 36 | listen_address = mkOption { 37 | type = str; 38 | description = '' 39 | Address to listen on for the Prometheus exporter. Empty string will listen on all interfaces. 40 | ''; 41 | default = ""; 42 | }; 43 | port = mkOption { 44 | type = int; 45 | description = '' 46 | Port to listen on for the Prometheus exporter. 47 | ''; 48 | default = 4243; 49 | }; 50 | openFirewall = mkOption { 51 | type = types.bool; 52 | default = false; 53 | description = '' 54 | Open port in firewall for incoming connections to the Prometheus exporter. 55 | ''; 56 | }; 57 | }; 58 | }; 59 | }; 60 | remotes = mkOption { 61 | description = "Ordered list of repositories to pull."; 62 | type = listOf (submodule { 63 | options = { 64 | name = mkOption { 65 | type = str; 66 | description = '' 67 | The name of the remote. 68 | ''; 69 | }; 70 | url = mkOption { 71 | type = str; 72 | description = '' 73 | The URL of the repository. 74 | ''; 75 | }; 76 | auth = mkOption { 77 | description = "Authentication options."; 78 | default = {}; 79 | type = submodule { 80 | options = { 81 | access_token_path = mkOption { 82 | type = str; 83 | default = ""; 84 | description = '' 85 | The path of the auth file. 86 | ''; 87 | }; 88 | }; 89 | }; 90 | }; 91 | timeout = mkOption { 92 | type = int; 93 | default = 300; 94 | description = '' 95 | Git fetch timeout in seconds. 96 | ''; 97 | }; 98 | branches = mkOption { 99 | description = "Branches to pull."; 100 | default = {}; 101 | type = submodule { 102 | options = { 103 | main = mkOption { 104 | default = {}; 105 | description = "The main branch to fetch."; 106 | type = submodule { 107 | options = { 108 | name = mkOption { 109 | type = str; 110 | default = "main"; 111 | description = "The name of the main branch."; 112 | }; 113 | }; 114 | }; 115 | }; 116 | testing = mkOption { 117 | default = {}; 118 | description = "The testing branch to fetch."; 119 | type = submodule { 120 | options = { 121 | name = mkOption { 122 | type = str; 123 | default = "testing-${config.services.comin.hostname}"; 124 | description = "The name of the testing branch."; 125 | }; 126 | }; 127 | }; 128 | }; 129 | }; 130 | }; 131 | }; 132 | poller = mkOption { 133 | default = {}; 134 | description = "The poller options."; 135 | type = submodule { 136 | options = { 137 | period = mkOption { 138 | type = types.int; 139 | default = 60; 140 | description = '' 141 | The poller period in seconds. 142 | ''; 143 | }; 144 | }; 145 | }; 146 | }; 147 | }; 148 | }); 149 | }; 150 | debug = mkOption { 151 | type = types.bool; 152 | default = false; 153 | description = '' 154 | Whether to run comin in debug mode. Be careful, secrets are shown!. 155 | ''; 156 | }; 157 | machineId = mkOption { 158 | type = types.nullOr types.str; 159 | default = null; 160 | description = '' 161 | The expected machine-id of the machine configured by 162 | comin. If not null, the configuration is only deployed 163 | when this specified machine-id is equal to the actual 164 | machine-id. 165 | This is mainly useful for server migration: this allows 166 | to migrate a configuration from a machine to another 167 | machine (with different hardware for instance) without 168 | impacting both. 169 | Note it is only used by comin at evaluation. 170 | ''; 171 | }; 172 | gpgPublicKeyPaths = mkOption { 173 | description = "A list of GPG public key file paths. Each of this file should contains an armored GPG key."; 174 | type = listOf str; 175 | default = []; 176 | }; 177 | }; 178 | }; 179 | } 180 | -------------------------------------------------------------------------------- /nix/module.nix: -------------------------------------------------------------------------------- 1 | { self }: { config, pkgs, lib, ... }: 2 | let 3 | cfg = config; 4 | yaml = pkgs.formats.yaml { }; 5 | cominConfig = { 6 | hostname = cfg.services.comin.hostname; 7 | state_dir = "/var/lib/comin"; 8 | flake_subdirectory = cfg.services.comin.flakeSubdirectory; 9 | remotes = cfg.services.comin.remotes; 10 | exporter = { 11 | listen_address = cfg.services.comin.exporter.listen_address; 12 | port = cfg.services.comin.exporter.port; 13 | }; 14 | gpg_public_key_paths = cfg.services.comin.gpgPublicKeyPaths; 15 | }; 16 | cominConfigYaml = yaml.generate "comin.yaml" cominConfig; 17 | 18 | inherit (pkgs.stdenv.hostPlatform) system; 19 | inherit (cfg.services.comin) package; 20 | in { 21 | imports = [ ./module-options.nix ]; 22 | config = lib.mkIf cfg.services.comin.enable { 23 | assertions = [ 24 | { assertion = package != null; message = "`services.comin.package` cannot be null."; } 25 | # If the package is null and our `system` isn't supported by the Flake, it's probably safe to show this error message 26 | { assertion = package == null -> lib.elem system (lib.attrNames self.packages); message = "comin: ${system} is not supported by the Flake."; } 27 | ]; 28 | 29 | environment.systemPackages = [ package ]; 30 | networking.firewall.allowedTCPPorts = lib.optional cfg.services.comin.exporter.openFirewall cfg.services.comin.exporter.port; 31 | # Use package from overlay first, then Flake package if available 32 | services.comin.package = lib.mkDefault pkgs.comin or self.packages.${system}.comin or null; 33 | systemd.services.comin = { 34 | wantedBy = [ "multi-user.target" ]; 35 | path = [ config.nix.package ]; 36 | # The comin service is restarted by comin itself when it 37 | # detects the unit file changed. 38 | restartIfChanged = false; 39 | serviceConfig = { 40 | ExecStart = 41 | (lib.getExe package) 42 | + (lib.optionalString cfg.services.comin.debug " --debug ") 43 | + " run " 44 | + "--config ${cominConfigYaml}"; 45 | Restart = "always"; 46 | }; 47 | }; 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | buildGoModule, 4 | git, 5 | makeWrapper, 6 | writeTextFile, 7 | }: 8 | 9 | let 10 | # - safe.directory: this is to allow comin to fetch local repositories belonging 11 | # to other users. Otherwise, comin fails with: 12 | # Pull from remote 'local' failed: unknown error: fatal: detected dubious ownership in repository 13 | # - core.hooksPath: to avoid Git executing hooks from a repository belonging to another user 14 | gitConfigFile = writeTextFile { 15 | name = "git.config"; 16 | text = '' 17 | [safe] 18 | directory = * 19 | [core] 20 | hooksPath = /dev/null 21 | ''; 22 | }; 23 | in 24 | 25 | buildGoModule rec { 26 | pname = "comin"; 27 | version = "0.6.0"; 28 | nativeCheckInputs = [ git ]; 29 | src = lib.fileset.toSource { 30 | root = ../.; 31 | fileset = lib.fileset.unions [ 32 | ../cmd 33 | ../internal 34 | ../go.mod 35 | ../go.sum 36 | ../main.go 37 | ]; 38 | }; 39 | vendorHash = "sha256-IE2yxFIIqbjjzBamwJg5vS36MMOkD7TJ4A5PKx3CRH4="; 40 | ldflags = [ 41 | "-X github.com/nlewo/comin/cmd.version=${version}" 42 | ]; 43 | buildInputs = [ makeWrapper ]; 44 | postInstall = '' 45 | # This is because Nix needs Git at runtime by the go-git library 46 | wrapProgram $out/bin/comin --set GIT_CONFIG_SYSTEM ${gitConfigFile} --prefix PATH : ${git}/bin 47 | ''; 48 | 49 | meta = { 50 | mainProgram = "comin"; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # comin - GitOps for NixOS Machines 2 | 3 | **comin** is a NixOS deployment tool operating in pull mode. Running 4 | on a machine, it periodically polls Git repositories and deploys the 5 | NixOS configuration associated to the machine. 6 | 7 | ## Features 8 | 9 | - :snowflake: Git push to deploy NixOS configurations 10 | - :construction: Support testing branches to [try changes](./docs/howtos.md#how-to-test-a-nixos-configuration-change) 11 | - :rocket: Poll [multiple Git remotes](./docs/generated-module-options.md#servicescominremotes) to avoid SPOF 12 | - :postbox: Support [machines migrations](./docs/howtos.md#how-to-migrate-a-configuration-from-a-machine-to-another-one) 13 | - :fast_forward: Fast iterations with [local remotes](./docs/howtos.md#iterate-faster-with-local-repository) 14 | - :satellite: Observable via [Prometheus metrics](./docs/generated-module-options.md#servicescominexporter) 15 | - :pushpin: Create and delete system profiles 16 | - :lock: Optionally check [Git commit signatures](./docs/howtos.md#check-git-commit-signatures) 17 | 18 | ## Quick start 19 | 20 | This is a basic `flake.nix` example: 21 | 22 | ```nix 23 | { 24 | inputs = { 25 | nixpkgs.url = "github:nixOS/nixpkgs"; 26 | comin = { 27 | url = "github:nlewo/comin"; 28 | inputs.nixpkgs.follows = "nixpkgs"; 29 | }; 30 | }; 31 | outputs = { self, nixpkgs, comin }: { 32 | nixosConfigurations = { 33 | myMachine = nixpkgs.lib.nixosSystem { 34 | system = "x86_64-linux"; 35 | modules = [ 36 | comin.nixosModules.comin 37 | ({...}: { 38 | services.comin = { 39 | enable = true; 40 | remotes = [{ 41 | name = "origin"; 42 | url = "https://gitlab.com/your/infra.git"; 43 | branches.main.name = "main"; 44 | }]; 45 | }; 46 | }) 47 | ]; 48 | }; 49 | }; 50 | }; 51 | } 52 | ``` 53 | 54 | This enables a systemd service, which periodically pulls the `main` 55 | branch of the repository `https://gitlab.com/your/infra.git` and 56 | deploys the NixOS configuration corresponding to the machine hostname 57 | `myMachine`. 58 | 59 | A new commit in the `main` branch of the repository 60 | `https://gitlab.com/your/infra.git` is then deployed in the next 60 61 | seconds. 62 | 63 | ## Documentation 64 | 65 | - [Howtos](./docs/howtos.md) 66 | - [Advanced Configuraion](./docs/advanced-config.md) 67 | - [Authentication](./docs/authentication.md) 68 | - [Comin module options](./docs/generated-module-options.md) 69 | - [Design](./docs/design.md) 70 | - [Contribute](./docs/contribute.md) 71 | --------------------------------------------------------------------------------