Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
Please login to access the resource
openSUSE:Factory
gitopper
gitopper-0.0.20.obscpio
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File gitopper-0.0.20.obscpio of Package gitopper
07070100000000000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001800000000gitopper-0.0.20/.github07070100000001000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000002200000000gitopper-0.0.20/.github/workflows07070100000002000081A400000000000000000000000166CD6167000001A6000000000000000000000000000000000000002900000000gitopper-0.0.20/.github/workflows/go.ymlname: Go on: [push, pull_request] jobs: build: name: Build and Test runs-on: ubuntu-latest strategy: matrix: go: [ 1.22.x ] steps: - name: Set up Go uses: actions/setup-go@v2 with: go-version: ${{ matrix.go }} - name: Check out code uses: actions/checkout@v2 - name: Build run: go build -v ./... - name: Test run: go test -v ./... 07070100000003000081A400000000000000000000000166CD616700000025000000000000000000000000000000000000001B00000000gitopper-0.0.20/.gitignoregitopper cmd/gitopperctl/gitopperctl 07070100000004000081A400000000000000000000000166CD616700002C5E000000000000000000000000000000000000001800000000gitopper-0.0.20/LICENSE Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 07070100000005000081A400000000000000000000000166CD6167000000D2000000000000000000000000000000000000001900000000gitopper-0.0.20/Makefile.PHONY: man man: mmark -man gitopper.8.md > gitopper.8 mmark -man cmd/gitopperctl/gitopperctl.8.md > cmd/gitopperctl/gitopperctl.8 mmark -man cmd/gitopperhdr/gitopperhdr.1.md > cmd/gitopperhdr/gitopperhdr.1 070701000000060000A1FF00000000000000000000000166CD61670000000D000000000000000000000000000000000000001A00000000gitopper-0.0.20/README.mdgitopper.8.md07070100000007000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001400000000gitopper-0.0.20/cmd07070100000008000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000002000000000gitopper-0.0.20/cmd/gitopperctl070701000000090000A1FF00000000000000000000000166CD616700000010000000000000000000000000000000000000002A00000000gitopper-0.0.20/cmd/gitopperctl/README.mdgitopperctl.8.md0707010000000A000081A400000000000000000000000166CD616700000F6A000000000000000000000000000000000000002E00000000gitopper-0.0.20/cmd/gitopperctl/gitopperctl.8.\" Generated by Mmark Markdown Processer - mmark.miek.nl .TH "GITOPPERCTL" 8 "March 2023" "System Administration" "Git Operations" .SH "GITOPPERCTL" .SH "NAME" .PP gitopperctl - interact remotely with gitopper .SH "SYNOPSIS" .PP \fB\fCgitopperctl [OPTION]...\fR \fIcommands\fP \fI@host\fP .SH "DESCRIPTION" .PP Gitopperctl is an utility to inspect and control gitopper remotely. The command line syntax follow other *-ctl tools a bit. .PP There are only a few options: .TP \fB-i value\fP identity file to use for SSH, this flag is mandatory .TP \fB-m\fP machine readable output (default: false), output JSON .TP \fB--help, -h\fP show help .PP The two main branches of use are \fB\fClist\fR and \fB\fCdo\fR \fBcommands\fP. Note the \fB\fC-i <sshkey>\fR argument is only shown once in these examples: .PP .RS .nf \&./gitopperctl \-i ~/.ssh/id\_ed25519\_gitopper list machine @<host> \&./gitopperctl list service @<host> \&./gitopperctl list service @<host> <service> .fi .RE .PP In order: .IP 1\. 4 List all machines defined in the config file for gitopper running on \fB\fC<host>\fR. .IP 2\. 4 List all services that are controlled on \fB\fC<host>\fR. .IP 3\. 4 List a specific service on \fB\fC<host>\fR. .PP Each will output a simple table with the information: .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 606eb576 OK 2022\-11\-18 13:29:44.824004812 +0000 UTC .fi .RE .PP Use \fB\fC--help\fR to show implemented subcommands. .SS "MANIPULATING SERVICES" .PP Freezing (make it stop updating to the latest commit), until a unfreeze: .PP .RS .nf \&./gitopperctl do freeze @<host> <service> \&./gitopperctl do unfreeze @<host> <service> .fi .RE .PP Rolling back to a previous commit, hash needs to be a valid hexadecimal value (meaning it must be of even length): .PP .RS .nf \&./gitopperctl do rollback @<host> <service> <hash> .fi .RE .PP And this can be abbreviated to: .PP .RS .nf \&./gitopperctl d r @<host> <service> <hash> .fi .RE .PP Or make it to pull now and now wait for the default wait duration to expire: .PP .RS .nf \&./gitopper do pull @<host> <service> .fi .RE .SH "EXAMPLE" .PP This is a small example of this tool interacting with the daemon. .IP \(bu 4 check current service .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 606eb576 OK 0001\-01\-01 00:00:00 +0000 UTC .fi .RE .IP \(bu 4 rollback .PP .RS .nf \&./gitopperctl do rollback @localhost grafana\-server 8df1b3db679253ba501d594de285cc3e9ed308ed .fi .RE .IP \(bu 4 check .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 606eb576 ROLLBACK 8df1b3db679253ba501d594de285cc3e9ed308ed 2022\-11\-18 13:28:42.619731556 +0000 UTC .fi .RE .IP \(bu 4 check do, rollback done. Now state is FREEZE .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 8df1b3db FREEZE ROLLBACK: 8df1b3db679253ba501d594de285cc3e9ed308ed 2022\-11\-18 13:29:17.92401403 +0000 UTC .fi .RE .IP \(bu 4 unfreeze and let it pick up changes again .PP .RS .nf \&./gitopperctl do unfreeze @localhost grafana\-server .fi .RE .IP \(bu 4 check the service .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 8df1b3db OK 2022\-11\-18 13:29:44.824004812 +0000 UTC .fi .RE .IP \(bu 4 and updated to new hash .PP .RS .nf \&./gitopperctl list service @localhost grafana\-server SERVICE HASH STATE INFO SINCE grafana\-server 606eb576 OK 2022\-11\-18 13:29:44.824004812 +0000 UTC .fi .RE 0707010000000B000081A400000000000000000000000166CD616700000D6B000000000000000000000000000000000000003100000000gitopper-0.0.20/cmd/gitopperctl/gitopperctl.8.md%%% title = "gitopperctl 8" area = "System Administration" workgroup = "Git Operations" %%% gitopperctl ===== ## Name gitopperctl - interact remotely with gitopper ## Synopsis `gitopperctl [OPTION]...` *commands* *@host* ## Description Gitopperctl is an utility to inspect and control gitopper remotely. The command line syntax follow other \*-ctl tools a bit. There are only a few options: **-i value** : identity file to use for SSH, this flag is mandatory **-m** : machine readable output (default: false), output JSON **--help, -h** : show help The two main branches of use are `list` and `do` **commands**. Note the `-i <sshkey>` argument is only shown once in these examples: ~~~ ./gitopperctl -i ~/.ssh/id_ed25519_gitopper list machine @<host> ./gitopperctl list service @<host> ./gitopperctl list service @<host> <service> ~~~ In order: 1. List all machines defined in the config file for gitopper running on `<host>`. 2. List all services that are controlled on `<host>`. 3. List a specific service on `<host>`. Each will output a simple table with the information: ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 606eb576 OK 2022-11-18 13:29:44.824004812 +0000 UTC ~~~ Use `--help` to show implemented subcommands. ### Manipulating Services Freezing (make it stop updating to the latest commit), until a unfreeze: ~~~ ./gitopperctl do freeze @<host> <service> ./gitopperctl do unfreeze @<host> <service> ~~~ Rolling back to a previous commit, hash needs to be a valid hexadecimal value (meaning it must be of even length): ~~~ ./gitopperctl do rollback @<host> <service> <hash> ~~~ And this can be abbreviated to: ~~~ ./gitopperctl d r @<host> <service> <hash> ~~~ Or make it to pull now and now wait for the default wait duration to expire: ~~~ ./gitopper do pull @<host> <service> ~~~ ## Example This is a small example of this tool interacting with the daemon. - check current service ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 606eb576 OK 0001-01-01 00:00:00 +0000 UTC ~~~ - rollback ~~~ ./gitopperctl do rollback @localhost grafana-server 8df1b3db679253ba501d594de285cc3e9ed308ed ~~~ - check ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 606eb576 ROLLBACK 8df1b3db679253ba501d594de285cc3e9ed308ed 2022-11-18 13:28:42.619731556 +0000 UTC ~~~ - check do, rollback done. Now state is FREEZE ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 8df1b3db FREEZE ROLLBACK: 8df1b3db679253ba501d594de285cc3e9ed308ed 2022-11-18 13:29:17.92401403 +0000 UTC ~~~ - unfreeze and let it pick up changes again ~~~ ./gitopperctl do unfreeze @localhost grafana-server ~~~ - check the service ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 8df1b3db OK 2022-11-18 13:29:44.824004812 +0000 UTC ~~~ - and updated to new hash ~~~ ./gitopperctl list service @localhost grafana-server SERVICE HASH STATE INFO SINCE grafana-server 606eb576 OK 2022-11-18 13:29:44.824004812 +0000 UTC ~~~ 0707010000000C000081A400000000000000000000000166CD61670000120C000000000000000000000000000000000000002800000000gitopper-0.0.20/cmd/gitopperctl/main.gopackage main import ( "encoding/json" "fmt" "io" "os" "strconv" "strings" "text/tabwriter" "github.com/miekg/gitopper/proto" "github.com/urfave/cli/v2" "go.science.ru.nl/log" ) func atMachine(ctx *cli.Context) (string, error) { at := ctx.Args().First() if at == "" { return "", fmt.Errorf("expected @<machine>") } if !strings.HasPrefix(at, "@") { return "", fmt.Errorf("expected @<machine>") } return at[1:], nil } func main() { app := &cli.App{ Flags: []cli.Flag{ &cli.StringFlag{ Name: "i", Value: "", Usage: "identity file", }, &cli.BoolFlag{ Name: "m", Usage: "machine readable output", }, }, Commands: []*cli.Command{ { Name: "list", Aliases: []string{"ls", "l"}, Usage: "list machines, services or a single service", Subcommands: []*cli.Command{ { Name: "machines", Aliases: []string{"m"}, Usage: "list machines @machine", Action: cmdMachines, }, { Name: "service", Aliases: []string{"s"}, Usage: "list service @machine [<service>]", Action: cmdService, }, }, }, { Name: "do", Aliases: []string{"d"}, Usage: "apply state changes to a service on a machine", Subcommands: []*cli.Command{ { Name: "freeze", Aliases: []string{"f"}, Usage: "do freeze @machine <service>", Action: cmdFreeze, }, { Name: "unfreeze", Aliases: []string{"u"}, Usage: "do unfreeze @machine <service>", Action: cmdUnfreeze, }, { Name: "rollback", Aliases: []string{"r"}, Usage: "do rollback @machine <service> <hash>", Action: cmdRollback, }, { Name: "pull", Aliases: []string{"p"}, Usage: "do pull @machine <service>", Action: cmdPull, }, }, }, }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } func cmdPull(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } service := ctx.Args().Get(1) if service == "" { return fmt.Errorf("need service") } _, err = querySSH(ctx, at, "/do/pull", service) return err } func cmdRollback(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } service := ctx.Args().Get(1) if service == "" { return fmt.Errorf("need service") } hash := ctx.Args().Get(2) if hash == "" { return fmt.Errorf("need hash to rollback to") } _, err = querySSH(ctx, at, "/do/rollback", service, hash) return err } func cmdUnfreeze(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } service := ctx.Args().Get(1) if service == "" { return fmt.Errorf("need service") } _, err = querySSH(ctx, at, "/do/unfreeze", service) return err } func cmdFreeze(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } service := ctx.Args().Get(1) if service == "" { return fmt.Errorf("need service") } _, err = querySSH(ctx, at, "/do/freeze", service) return err } func tblPrint(writer io.Writer, line []string) { fmt.Fprintln(writer, strings.Join(line, "\t")) } func cmdService(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } var body []byte service := ctx.Args().Get(1) if service != "" { body, err = querySSH(ctx, at, "/list/service", service) } else { body, err = querySSH(ctx, at, "/list/service") } if err != nil { return err } ls := proto.ListServices{} if err := json.Unmarshal(body, &ls); err != nil { return err } if ctx.Bool("m") { fmt.Print(string(body)) return nil } tbl := new(tabwriter.Writer) tbl.Init(os.Stdout, 0, 8, 1, ' ', 0) tblPrint(tbl, []string{"#", "SERVICE", "HASH", "STATE", "INFO", "SINCE"}) for i, ls := range ls.ListServices { tblPrint(tbl, []string{strconv.FormatInt(int64(i), 10), ls.Service, ls.Hash, ls.State, ls.StateInfo, ls.StateChange}) } _ = tbl.Flush() return nil } func cmdMachines(ctx *cli.Context) error { at, err := atMachine(ctx) if err != nil { return err } body, err := querySSH(ctx, at, "/list/machine") if err != nil { return err } lm := proto.ListMachines{} if err := json.Unmarshal(body, &lm); err != nil { return err } if ctx.Bool("m") { fmt.Print(string(body)) return nil } tbl := new(tabwriter.Writer) tbl.Init(os.Stdout, 0, 8, 1, ' ', 0) tblPrint(tbl, []string{"#", "MACHINE", "ACTUAL"}) for i, m := range lm.ListMachines { tblPrint(tbl, []string{strconv.FormatInt(int64(i), 10), m.Machine, m.Actual}) } _ = tbl.Flush() return nil } 0707010000000D000081A400000000000000000000000166CD616700000503000000000000000000000000000000000000002700000000gitopper-0.0.20/cmd/gitopperctl/ssh.gopackage main import ( "bytes" "fmt" "io/ioutil" "os/user" "strings" "github.com/urfave/cli/v2" "golang.org/x/crypto/ssh" ) func querySSH(ctx *cli.Context, at, command string, args ...string) ([]byte, error) { ident := ctx.String("i") if ident == "" { return nil, fmt.Errorf("identity file not given, -i flag") } port := ctx.String("p") if port == "" { port = "2222" } at = at + ":" + port key, err := ioutil.ReadFile(ident) if err != nil { return nil, err } // Create the Signer for this private key. signer, err := ssh.ParsePrivateKey(key) if err != nil { return nil, err } user, err := user.Current() if err != nil { return nil, err } config := &ssh.ClientConfig{ User: user.Username, Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } client, err := ssh.Dial("tcp", at, config) if err != nil { return nil, err } defer client.Close() ss, err := client.NewSession() if err != nil { return nil, err } defer ss.Close() // makes this buffer bounded...? stdoutBuf := &bytes.Buffer{} ss.Stdout = stdoutBuf cmdline := command + " " + strings.Join(args, " ") if err := ss.Run(cmdline); err != nil { return nil, err } return stdoutBuf.Bytes(), nil } 0707010000000E000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000002000000000gitopper-0.0.20/cmd/gitopperhdr0707010000000F0000A1FF00000000000000000000000166CD616700000010000000000000000000000000000000000000002A00000000gitopper-0.0.20/cmd/gitopperhdr/README.mdgitopperhdr.1.md07070100000010000081A400000000000000000000000166CD61670000043B000000000000000000000000000000000000002E00000000gitopper-0.0.20/cmd/gitopperhdr/gitopperhdr.1.\" Generated by Mmark Markdown Processer - mmark.miek.nl .TH "GITOPPERHDR" 1 "March 2023" "System Administration" "Git Operations" .SH "GITOPPERHDR" .SH "NAME" .PP gitopperhdr - output lines telling where to edit the file managed by gitopper .SH "SYNOPSIS" .PP \fB\fCgitopperhdr\fR \fBFILE\fP .SH "DESCRIPTION" .PP This little command outputs a few lines that can be inserted into any file managed by gitopper. It shows the upstream repo where edits can be done. If that URL contains "github.com" it uses GitHub's way of pointing to files in a repo, otherwise GitLab is assumed. This command must be called while sitting inside a git repository. .PP Currently it outputs (in English, Dutch is also an option): .PP .RS .nf # Do not edit this file, it's managed by `gitopper'. The canonical source can be found at: # https://github.com/miekg/gitopper/blob/cmd/gitopperhdr/main.go .fi .RE .PP Options are: .TP \fB-p\fP set comment character, defaults to \fB\fC#\fR .TP \fB-l\fP set language, \fB\fCen\fR is the default, the other supported value is \fB\fCnl\fR for Dutch. 07070100000011000081A400000000000000000000000166CD6167000003C5000000000000000000000000000000000000003100000000gitopper-0.0.20/cmd/gitopperhdr/gitopperhdr.1.md%%% title = "gitopperhdr 1" area = "System Administration" workgroup = "Git Operations" %%% gitopperhdr ===== ## Name gitopperhdr - output lines telling where to edit the file managed by gitopper ## Synopsis `gitopperhdr` **FILE** ## Description This little command outputs a few lines that can be inserted into any file managed by gitopper. It shows the upstream repo where edits can be done. If that URL contains "github.com" it uses GitHub's way of pointing to files in a repo, otherwise GitLab is assumed. This command must be called while sitting inside a git repository. Currently it outputs (in English, Dutch is also an option): ~~~ # Do not edit this file, it's managed by `gitopper'. The canonical source can be found at: # https://github.com/miekg/gitopper/blob/cmd/gitopperhdr/main.go ~~~ Options are: **-p** : set comment character, defaults to `#` **-l** : set language, `en` is the default, the other supported value is `nl` for Dutch. 07070100000012000081A400000000000000000000000166CD6167000009D7000000000000000000000000000000000000002800000000gitopper-0.0.20/cmd/gitopperhdr/main.gopackage main import ( "fmt" "os" "strings" "github.com/miekg/gitopper/gitcmd" "github.com/urfave/cli/v2" "go.science.ru.nl/log" ) func main() { app := &cli.App{ Flags: []cli.Flag{ &cli.StringFlag{ Name: "p", Value: "#", Usage: "comment prefix to use", }, &cli.StringFlag{ Name: "l", Value: "en", Usage: "language to use", }, }, Name: "hdr", Usage: "print a header suitable for inclusion in a file", Action: func(ctx *cli.Context) error { file := ctx.Args().Get(0) if file == "" { log.Fatal("Want a file argument") } gc := gitcmd.New("", "", "", "", nil) // don't need any of these. url := gc.OriginURL() if url == "" { log.Fatal("Failed to get upstream origin URL") } branch := gc.BranchCurrent() if branch == "" { log.Fatal("Failed to get current branch") } relpath := gc.LsFile(file) if relpath == "" { log.Warningf("Failed to get relative path for: %q, omitting file path from output", file) } comment := ctx.String("p") headerfmt, ok := Header[ctx.String("l")] if !ok { headerfmt = Header["en"] } fmt.Printf(headerfmt, comment, comment, transformURL(ctx, url, relpath, branch)) fmt.Println() return nil }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } // transformURL will transform a git@ url to a https:// one, taking into gitlab vs github into account. The returns // string is a valid URL that points to the file in relpath. func transformURL(ctx *cli.Context, url, relpath, branch string) string { // github: https://github.com/miekg/gitopper/blob/main/proto/proto.go // gitlab: https://gitlab.science.ru.nl/cncz/go/-/blob/main/cmd/graaf/graaf.go // // git@github.com:miekg/gitopper.git will be transformed to: // https://github.com/miekg/gitopper if strings.HasPrefix(url, "git@") { if strings.HasSuffix(url, ".git") { url = url[:len(url)-len(".git")] } url = url[len("git@"):] url = strings.Replace(url, ":", "/", 1) url = "https://" + url } if relpath == "" { return url } // url has been normalized, add path sep := "/-/blob/" + branch + "/" if strings.Contains(url, "//github.com/") { sep = "/blob/" + branch + "/" } return url + sep + relpath } var Header = map[string]string{ "en": "%s Do not edit this file, it's managed by `gitopper'. The canonical source can be found at:\n%s %s", "nl": "%s Bewerk dit bestand niet, het wordt beheerd door `gitopper'. De canonieke bron is te vinden op:\n%s %s", } 07070100000013000081A400000000000000000000000166CD61670000088F000000000000000000000000000000000000001A00000000gitopper-0.0.20/config.gopackage main import ( "bytes" "context" "crypto/sha1" "fmt" "io/ioutil" "os" "syscall" "time" "github.com/gliderlabs/ssh" toml "github.com/pelletier/go-toml/v2" "go.science.ru.nl/log" ) // Config holds the gitopper config file. It's is updated every so often to pick up new changes. type Config struct { Global `toml:"global"` Services []*Service } type Global struct { *Service Keys []*Key } type Key struct { Path string RO bool `toml:"ro"` // treat key as ro, and disallow "write" commands ssh.PublicKey `toml:"-"` } func parseConfig(doc []byte) (c Config, err error) { t := toml.NewDecoder(bytes.NewReader(doc)) t.DisallowUnknownFields() err = t.Decode(&c) return c, err } // Valid checks the config in c and returns nil of all mandatory fields have been set. func (c Config) Valid() error { if len(c.Global.Keys) == 0 { return fmt.Errorf("at least one public key should be specified") } for i, serv := range c.Services { s := serv.merge(c.Global) if s.Machine == "" { return fmt.Errorf("machine #%d, has empty machine name", i) } if s.Upstream == "" { return fmt.Errorf("machine #%d %q, has empty upstream", i, s.Machine) } if s.Mount == "" { return fmt.Errorf("machine #%d %q, has empty mount", i, s.Machine) } if s.Service == "" { return fmt.Errorf("machine #%d %q, has empty service", i, s.Service) } } return nil } // trackConfig will sha1 sum the contents of file and if it differs from previous runs, will SIGHUP ourselves so we // exist with status code 2, which in turn will systemd restart us again. func trackConfig(ctx context.Context, file string, done chan<- os.Signal) { hash := "" for { select { case <-time.After(30 * time.Second): case <-ctx.Done(): return } doc, err := ioutil.ReadFile(file) if err != nil { log.Warningf("Failed to read config %q: %s", file, err) continue } sha := sha1.New() sha.Write(doc) hash1 := string(sha.Sum(nil)) if hash == "" { hash = hash1 continue } if hash1 != hash { log.Info("Config change detected, sending SIGHUP") // haste our exit (can this block?) done <- syscall.SIGHUP return } } } 07070100000014000081A400000000000000000000000166CD6167000002D8000000000000000000000000000000000000001C00000000gitopper-0.0.20/config.toml[global] upstream = "https://github.com/miekg/gitopper-config" branch = "main" mount = "/tmp/gitopper" keys = [ { path = "keys/miek_id_ed25519_gitopper.pub", ro = true }, { path = "/local/home/miek/.ssh/id_ed25519_gitopper.pub"}, ] [[services]] machine = "localhost" service = "prometheus" user = "prometheus" package = "prometheus" action = "reload" dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, ] # docker compose needs to work with systemd template files [[services]] machine = "docker-test" service = "docker-compose@caddy" user = "docker" #package = "docker-compose" action = "reload" dirs = [ { local = "/tmp/docker-compose.yml", link = "docker-compose/caddy/docker-compose.yml", file = true }, ] 07070100000015000081A400000000000000000000000166CD616700000492000000000000000000000000000000000000001F00000000gitopper-0.0.20/config_test.gopackage main import ( "testing" ) func TestValidConfig(t *testing.T) { const conf = ` [global] upstream = "https://github.com/miekg/gitopper-config" mount = "/tmp" branch = "main" [[services]] machine = "localhost" branch = "canary" service = "prometheus" user = "grafana" package = "grafana" action = "reload" dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, ] ` c, err := parseConfig([]byte(conf)) if err != nil { t.Fatalf("expected to parse config, but got: %s", err) } serv := c.Services[0] serv = serv.merge(c.Global) if serv.Mount == "" { t.Errorf("expected service to have Mount, got none") } if serv.Upstream == "" { t.Errorf("expected service to have Upstream, got none") } if serv.Branch != "canary" { t.Errorf("expected Branch to be %s, got %s", "canary", serv.Branch) } } func TestInvalidConfig(t *testing.T) { const conf = ` [global] upstream = "https://github.com/miekg/gitopper-config" mount = "/tmp" [[services]] machine = "localhost" brokenbranch = "main" service = "prometheus" ` if _, err := parseConfig([]byte(conf)); err == nil { t.Fatalf("expected to fail to parse config, but got nil error") } } 07070100000016000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001700000000gitopper-0.0.20/gitcmd07070100000017000081A400000000000000000000000166CD6167000004F2000000000000000000000000000000000000002300000000gitopper-0.0.20/gitcmd/diffstat.gopackage gitcmd import ( "bufio" "bytes" "strings" ) // OfInterest will grep the diff stat for directories we care about. The returns boolen is true when we find a hit. // // We do a git pull, but we want to know if things changed that are of interest. // This is done by parsing the diffstat on the git pull, not the best way, but it works. // Problem is here is to keep track _when_ things changed, i.e. we can look in the revlog, but then // we need to track that we saw a change. Parsing the diff stat seems simpler and more atomic in that // regard. func (g *Git) OfInterest(data []byte) bool { scanner := bufio.NewScanner(bytes.NewReader(data)) // Diff stat snippet: // // Fast-forward // provisioning-systems.md | 139 +++++++++++++++++ // 1 file changed, 139 insertions(+) // create mode 100644 provisioning-systems.md // // Start with a space, then non-space, and a pipe symbol somewhere in there. // this is O(n * m), but the number of dirs is usually very small, and the diffstat is // bunch of lines usually < 1000. So it's not too bad. for scanner.Scan() { text := scanner.Text() for _, d := range g.dirs { if strings.Contains(text, d) { return true } } } if scanner.Err() != nil { return false } return false } 07070100000018000081A400000000000000000000000166CD6167000014C7000000000000000000000000000000000000001E00000000gitopper-0.0.20/gitcmd/git.go// Package gitcmd has a bunch of convience functions to work with Git. // Each machine should use it's own Git. package gitcmd import ( "bytes" "context" "fmt" "os" "os/exec" "path" "syscall" "github.com/miekg/gitopper/osutil" "go.science.ru.nl/log" ) type Git struct { upstream string branch string mount string dirs []string user string cwd string } // New returns a pointer to an intialized Git. func New(upstream, branch, mount, user string, dirs []string) *Git { // Git is starting to look a lot like Service.... g := &Git{ upstream: upstream, mount: mount, dirs: dirs, user: user, branch: branch, } return g } func (g *Git) run(args ...string) ([]byte, error) { ctx := context.TODO() cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = g.cwd cmd.Env = []string{"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null"} if g.user != "" { uid, gid := osutil.User(g.user) cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} } log.Debugf("running in %q as %q %v", cmd.Dir, g.user, cmd.Args) out, err := cmd.CombinedOutput() if len(out) > 0 { log.Debug(string(out)) } metricGitOps.Inc() if err != nil { metricGitFail.Inc() } return bytes.TrimSpace(out), err } // IsCheckedOut will check g.mount and if it has an .git sub directory we assume the checkout has been done. func (g *Git) IsCheckedOut() bool { info, err := os.Stat(path.Join(g.mount, ".git")) if err != nil { return false } return info.Name() == ".git" && info.IsDir() } // Checkout will do the initial check of the git repo. If the g.mount directory already exist and has // a .git subdirectory, it will assume the checkout has been done during a previuos run. func (g *Git) Checkout() error { if g.IsCheckedOut() { return nil } if err := os.MkdirAll(g.mount, 0775); err != nil { log.Errorf("Directory %q can not be created", g.mount) return fmt.Errorf("failed to create directory %q: %s", g.mount, err) } if os.Geteuid() == 0 { // set g.mount to the correct owner, if we are root uid, gid := osutil.User(g.user) if err := os.Chown(g.mount, int(uid), int(gid)); err != nil { log.Errorf("Directory %q can not be chown-ed to %q: %s", g.mount, g.user, err) return fmt.Errorf("failed to chown directory %q to %q: %s", g.mount, g.user, err) } } g.cwd = "" _, err := g.run("clone", "-b", g.branch, "--filter=blob:none", "--no-checkout", "--sparse", g.upstream, g.mount) if err != nil { return err } g.cwd = g.mount defer func() { g.cwd = "" }() args := []string{"sparse-checkout", "set"} args = append(args, g.dirs...) _, err = g.run(args...) if err != nil { return err } _, err = g.run("checkout") return err } // Pull pulls from upstream. If the returned bool is true there were updates. func (g *Git) Pull() (bool, error) { if err := g.Stash(); err != nil { return false, err } g.cwd = g.mount defer func() { g.cwd = "" }() if _, err := g.run("fetch"); err != nil { return false, err } out, err := g.run("diff", "--stat=4096", "--name-only", g.branch, fmt.Sprintf("origin/%s", g.branch)) if err != nil { return false, err } if _, err := g.run("merge"); err != nil { return false, err } return g.OfInterest(out), nil } // Hash returns the git hash of HEAD in the repo in g.mount. Empty string is returned in case of an error. // The hash is always truncated to 8 hex digits. func (g *Git) Hash() string { g.cwd = g.mount defer func() { g.cwd = "" }() out, err := g.run("rev-parse", "HEAD") if err != nil { return "" } if len(out) < 8 { return "" } return string(out)[:8] } // Rollback checks out commit <hash>, and return nil if no errors are encountered. func (g *Git) Rollback(hash string) error { if err := g.Stash(); err != nil { return err } g.cwd = g.mount defer func() { g.cwd = "" }() _, err := g.run("checkout", hash) return err } // Stash runs a git stash func (g *Git) Stash() error { g.cwd = g.mount defer func() { g.cwd = "" }() _, err := g.run("stash") return err } func (g *Git) Repo() string { return g.mount } // these methods below are only used in gitopperhdr. // OriginURL returns the value of git config --get remote.origin.url // The working directory for the git command is set to PWD. func (g *Git) OriginURL() string { wd, err := os.Getwd() if err != nil { return "" } g.cwd = wd defer func() { g.cwd = "" }() out, err := g.run("config", "--get", "remote.origin.url") if err != nil { return "" } return string(out) } // LsFile return the relative path of name inside the git repository. // The working directory for the git command is set to PWD. func (g *Git) LsFile(name string) string { wd, err := os.Getwd() if err != nil { return "" } g.cwd = wd defer func() { g.cwd = "" }() out, err := g.run("ls-files", "--full-name", name) if err != nil { return "" } return string(out) } // BranchCurrent shows the current branch. // The working directory for the git command is set to PWD. func (g *Git) BranchCurrent() string { wd, err := os.Getwd() if err != nil { return "" } g.cwd = wd defer func() { g.cwd = "" }() out, err := g.run("branch", "--show-current") if err != nil { return "" } return string(out) } 07070100000019000081A400000000000000000000000166CD616700000782000000000000000000000000000000000000002300000000gitopper-0.0.20/gitcmd/git_test.gopackage gitcmd import ( "encoding/hex" "testing" "go.science.ru.nl/log" ) func TestHash(t *testing.T) { log.Discard() g := New("", "", ".", "", nil) hash := g.Hash() if hash == "" { t.Fatal("Failed to get hash") } if len(hash) != 8 { t.Fatalf("Hash length should be 8, got %d", len(hash)) } if _, err := hex.DecodeString(hash); err != nil { t.Fatalf("Failed to decode hash: %s", err) } } func TestDiffStatOK(t *testing.T) { g := New("", "", ".", "", []string{"my/stuff"}) data := []byte(`remote: Enumerating objects: 10, done. remote: Counting objects: 100% (10/10), done. remote: Compressing objects: 100% (9/9), done. remote: Total 9 (delta 4), reused 0 (delta 0), pack-reused 0 Unpacking objects: 100% (9/9), 4.80 KiB | 1.20 MiB/s, done. From deb.atoom.net:/git/miek/docs * branch master -> FETCH_HEAD 37a1ec8..7e019a1 master -> origin/master Updating 37a1ec8..7e019a1 Fast-forward my/stuff/file.md | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 provisioning-systems.md `) if !g.OfInterest(data) { t.Fatal("Expected to find paths of interest, got none") } } func TestDiffStatFail(t *testing.T) { g := New("", "", ".", "", []string{"other/stuff"}) data := []byte(`remote: Enumerating objects: 10, done. remote: Counting objects: 100% (10/10), done. remote: Compressing objects: 100% (9/9), done. remote: Total 9 (delta 4), reused 0 (delta 0), pack-reused 0 Unpacking objects: 100% (9/9), 4.80 KiB | 1.20 MiB/s, done. From deb.atoom.net:/git/miek/docs * branch master -> FETCH_HEAD 37a1ec8..7e019a1 master -> origin/master Updating 37a1ec8..7e019a1 Fast-forward my/stuff/file.md | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 provisioning-systems.md `) if g.OfInterest(data) { t.Fatal("Expected to find _no_ paths of interest, but got some") } } 0707010000001A000081A400000000000000000000000166CD616700000220000000000000000000000000000000000000002200000000gitopper-0.0.20/gitcmd/metrics.gopackage gitcmd import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( metricGitFail = promauto.NewCounter(prometheus.CounterOpts{ Namespace: "gitopper", Subsystem: "machine", Name: "git_errors_total", Help: "Total number of git operations that failed.", }) metricGitOps = promauto.NewCounter(prometheus.CounterOpts{ Namespace: "gitopper", Subsystem: "machine", Name: "git_ops_total", Help: "Total number of git operations.", }) ) 0707010000001B000081A400000000000000000000000166CD61670000300D000000000000000000000000000000000000001B00000000gitopper-0.0.20/gitopper.8.\" Generated by Mmark Markdown Processer - mmark.miek.nl .TH "GITOPPER" 8 "March 2023" "System Administration" "Git Operations" .SH "GITOPPER" .SH "NAME" .PP gitopper - watch a git repository, pull changes and reload the server process .SH "SYNOPSIS" .PP \fB\fCgitopper [OPTION]...\fR \fB\fC-c\fR \fBCONFIG\fP .SH "DESCRIPTION" .PP Gitopper is GitOps for non-Kubernetes folks it watches a remote git repo, pulls changes and HUP the server (service) process. .PP A sparse (but with full history) git checkout will be done, so each service will only see the files it will actually need. Several bind mounts are then setup to give the service access to the file(s) in Git. If the target directories don't exist, they will be created, with the current user - if specified. .PP This tool does little more than just pull the repo, but the little it brings to the table allows for a GitOps workflow without resorting to Kubernetes like environments. .PP The Git repository that you are using to provision the services must have at least one (sub)directory for each service. .PP Gitopper will install packages if told to do so. It will not upgrade or downgrade them, assuming there is a better way of doing those. .PP The remote interface of gitopper uses SSH keys for authentication, this hopefully helps to fit in, in a sysadmin organisation. .PP The following features are implemented: .IP \(bu 4 \fIMetrics\fP: are included see below, they export a Git hash, so a rollout can be tracked. .IP \(bu 4 \fIDiff detection\fP: possible using the metrics or gitopperctl. .IP \(bu 4 \fIOut of band rollbacks\fP: use gitopperctl to bypass the normal Git workflow. .IP \(bu 4 \fINo client side processing\fP: files are used as they are in the Git repo. .IP \(bu 4 \fICanarying\fP: give a service a different branch to check out. .PP The options are: .TP \fB-h, --hosts strings\fP hosts (comma separated) to impersonate, local hostname is always added .TP \fB-c, --config string\fP config file to read .TP \fB-s, --ssh string\fP ssh address to listen on (default ":2222") .TP \fB-m, --metric string\fP http metrics address to listen on (default ":9222") .TP \fB-d, --debug\fP enable debug logging .TP \fB-r, --restart\fP send SIGHUP to ourselves when config changes .TP \fB-o, --root\fP require root permission, setting to false can aid in debugging (default true) .TP \fB-t, --duration duration\fP default duration between pulls (default 5m0s) .PP For bootstrapping gitopper itself the following options are available: .TP \fB-U, --upstream string\fP use this git repo to clone and to bootstrap from .TP \fB-D, --directory string\fP directory to sparse checkout (default "gitopper") .TP \fB-B, --branch string\fP check out in this branch (default "main") .TP \fB-M, --mount string\fP check out into this directory, -c is relative to this directory .TP \fB-P, --pull\fP pull (update) the git repo to the newest version before starting .SH "QUICK START" .IP \(bu 4 Generate a toy SSH key: \fB\fCssh-keygen -t ed25519\fR and make it write to an \fB\fCid_ed25519_gitopper\fR file. .IP \(bu 4 Put the path to the \fIPUBLIC\fP key (ending in .pub) in the \fB\fCkeys\fR fields of \fB\fC[global]\fR of the following config.toml file. .IP \(bu 4 Start as root: \fB\fCsudo ./gitopper -c config.toml -h localhost\fR .PP .RS .nf [global] upstream = "https://github.com/miekg/gitopper\-config" branch = "main" mount = "/tmp/gitopper" keys = [ { path = "keys/id\_ed25519\_gitopper.pub", ro = true }, ] [[services]] machine = "localhost" service = "prometheus" user = "prometheus" package = "prometheus" action = "reload" dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, ] .fi .RE .PP And things should work then. I.e. in /etc/prometheus you should see the content of the \fImiekg/gitopper-config\fP repository. Note that the prometheus package is installed, because \fB\fCpackage\fR is mentioned in the config file. .PP The checked out git repo in /tmp/prometheus should \fIonly\fP contain the prometheus directory thanks to the sparse checkout. Changes made to any other subdirectory in that repo do not trigger a prometheus reload. .PP Then with gitopperctl(8) you can query the server: .PP .RS .nf \&./gitopperctl \-i <path\-to\-your\-key> list service @localhost # SERVICE HASH STATE INFO CHANGED 0 prometheus 606eb576 OK Fri, 18 Nov 2022 09:14:52 UTC .fi .RE .SH "SERVICES" .PP A service can be in 5 states: OK, FREEZE, ROLLBACK (which is a FREEZE to a previous commit) and BROKEN/DIFF. .PP These states are not carried over when gitopper crashes/stops (maybe we want this to be persistent, would be nice to have this state in the git repo somehow?). .IP \(bu 4 \fB\fCOK\fR: everything is running and we're tracking upstream. .IP \(bu 4 \fB\fCFREEZE\fR: everything is running, but we're not tracking upstream. .IP \(bu 4 \fB\fCROLLBACK\fR: everything is running, but we're not tracking upstream \fIand\fP we're pinned to an older commit. This state is quickly followed by FREEZE if we were successful rolling back, otherwise BROKEN (systemd error) of DIFF (git error) .IP \(bu 4 \fB\fCBROKEN\fR: something with the service is broken, we're still tracking upstream. I.e. systemd error. .IP \(bu 4 \fB\fCDIFF\fR: the git repository can't be reconciled with upstream. I.e. git error. .PP ROLLBACK is a transient state and quickly moves to FREEZE, unless something goes wrong then it becomes BROKEN, or DIFF depending on what goes wrong (systemd, or git respectively). .PP .RS .nf +\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-+ | | v | *OK \-\-\-\-\-\-\-> ROLLBACK \-\-\-> FREEZE | / \\ | | / \\ v | | | | | v v | | BROKEN DIFF | | | | | | | | | +\-\-\-\-\-\-\-\-+\-\-\-\-\-\-\-\-\-+\-\-\-\-\-\-+ .fi .RE .IP \(bu 4 \fB\fC*OK\fR is the start state .IP \(bu 4 from \fB\fCOK\fR and \fB\fCFREEZE\fR we can still end up in \fB\fCBROKEN\fR and \fB\fCFREEZE\fR and vice versa. .SH "CONFIG FILE" .PP .RS .nf # global options are applied if a service doesn't list them [global] upstream = "https://github.com/miekg/gitopper\-config" # repository where to download from mount = "/tmp" # directory where to download to, mount+service is used as path # ssh keys that are allowed in via authorized keys keys =[ { path = "keys/miek\_id\_ed25519\_gitopper.pub" }, { path = "keys/another\_key.pub", ro = true }, ] # each managed service has an entry like this [[services]] machine = "prometheus" # hostname of the machine, so a host knows when to pick this up. service = "prometheus" # service identifier, if it's used by systemd it must be the systemd service name action = "reload" # call systemctl <action> <service> when the git repo changes, may be empty branch = "main" # what branch to check out package = "prometheus" # as used by package mgmt, may be empty (not implemented yet) user = "prometheus" # do the check out with this user # what directories or files from the repo to mount under the local directories dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, # prometheus/etc *in the repo* should be mounted under /etc/prometheus { local = "/etc/caddy/Caddyfile", link = "caddy/etc/Caddyfile", file = true }, # caddy/etc/Caddyfile *in the repo* should be mounted under /etc/caddy/Caddyfile ] .fi .RE .PP Note that \fB\fCmachine\fR above should match either the machine name ($HOSTNAME) or any of the values you give on the \fB\fC-h\fR flag. This allows you to create services that run everywhere, by defining a service that have name (say) "localhost" and then deploying gitopper with \fB\fC-h localhost\fR on every machine. .PP Options for each service: .IP \(bu 4 \fB\fCmachine\fR: the machine where this service should be active. By default \fB\fCgitopper\fR will know the current hostname, but multiple aliases may be given to it via the \fB\fC-h\fR flag. .IP \(bu 4 \fB\fCservice\fR: what systemd unit file is used to call \fB\fCaction\fR on. If service contains an \fB\fC@\fR a service template unit is assumed and gitopper will then run \fB\fCsystemctl enable <service>\fR to enable the service template. .IP \(bu 4 \fB\fCaction\fR: action to use when calling \fB\fCsystemctl <service>\fR. If empty no systemd command will be issued when the repo changes. .IP \(bu 4 \fB\fCbranch\fR: what branch to use in the checked out repo. Note different branches that use the \fIsame\fP repository on disk, will error on startup. .IP \(bu 4 \fB\fCpackage\fR: what package to install for this service. If empty, no package will be installed. .IP \(bu 4 \fB\fCuser\fR: what user should the git repository belong to. .IP \(bu 4 \fB\fCdirs\fR: describe the mapping between directories and files in the repository and on the local disk. \fB\fClocal\fR is the \fIon disk\fP name, and \fB\fClink\fR is the \fIrelative\fP path of the directory or file in the git repo. If a single file is used, \fB\fCfile\fR should be set to true. .SS "HOW TO BREAK IT" .PP Moving to a new user, will break git pull, with an error like 'dubious ownership of repository'. If you want a different owner for a service, it's best to change the mount as well so you get a new repo. Gitopper is currently not smart enough to detect this and fix things on the fly. .SH "INTERFACE" .PP Gitopper opens two ports: 9222 for metrics and 2222 for the rest-protocol-over-SSH. For any interaction with gitopper over this port your key must be configured for it. .PP The following services are implemented: .IP \(bu 4 List all defined machines. .IP \(bu 4 List services run on the machine. .IP \(bu 4 List a specific service. .IP \(bu 4 Freeze a service to the current git commit. .IP \(bu 4 Unfreeze a service, i.e. to let it pull again. .IP \(bu 4 Rollback a service to a specific commit. .PP For each of these gitopperctl(8) will execute a "command" and will parse the returned JSON into a nice table. .SH "METRICS" .PP The following metrics are exported: .IP \(bu 4 gitopper_service_state{"service"} <state> .IP \(bu 4 gitopper_service_change_time_seconds{"service"} <epoch> .IP \(bu 4 gitopper_machine_git_errors_total - total number of errors when running git. .IP \(bu 4 gitopper_machine_git_ops_total - total number of git runs. .PP Metrics are available under the /metrics endpoint on port 9222. .SH "EXIT CODE" .PP Gitopper has following exit codes: .PP 0 - normal exit 2 - SIGHUP seen (signal to systemd to restart us) .SH "BOOTSTRAPPING" .PP There are a couple of options that allow gitopper to bootstrap itself \fIand\fP make gitopper to be managed by gitopper. Basically those options allow you to specify a service on the command line. Gitopper will check out the repo and then proceed to read the config \fIin that repo\fP and setup everything from there. .PP I.e.: .PP .RS .nf \&... \-c config.toml \-U https://github.com/miekg/gitopper\-config \-D gitopper \-M /tmp/ .fi .RE .PP Will sparse check out (only the \fB\fCgitopper\fR (-D flag) directory) of the repo \fIgitopper-config\fP (-U flag) in /tmp/gitopper (-M flag, internally '/gitopper' is added) and will then proceed to parse the config file /tmp/gitopper/gitopper/config.toml and proceed with a normal startup. .PP Note this setup implies that you \fImust\fP place config.toml \fIinside\fP a \fB\fCgitopper\fR directory, just as the other services must have their own subdirectories, gitopper needs one too. .PP The gitopper service self is \fIalso\fP added to the managed services which you can inspect with gitopperctl(8). .PP Any keys that have \fIrelative\fP paths, will also be changed to key inside this Git managed directory and pick up keys \fIfrom that repo\fP. .PP The \fB\fC-P\fR flag can be given to pull the repository even if it already exists, sometimes you need to the newest version to properly bootstrap. For normal services the "git pull" routine will automatically rectify it and restart the service. .SH "SEE ALSO" .PP See this design doc \[la]https://miek.nl/2022/november/15/provisioning-services/\[ra], and gitopperctl(8). 0707010000001C000081A400000000000000000000000166CD616700002C10000000000000000000000000000000000000001E00000000gitopper-0.0.20/gitopper.8.md%%% title = "gitopper 8" area = "System Administration" workgroup = "Git Operations" %%% gitopper ===== ## Name gitopper - watch a git repository, pull changes and reload the server process ## Synopsis `gitopper [OPTION]...` `-c` **CONFIG** ## Description Gitopper is GitOps for non-Kubernetes folks it watches a remote git repo, pulls changes and HUP the server (service) process. A sparse (but with full history) git checkout will be done, so each service will only see the files it will actually need. Several bind mounts are then setup to give the service access to the file(s) in Git. If the target directories don't exist, they will be created, with the current user - if specified. This tool does little more than just pull the repo, but the little it brings to the table allows for a GitOps workflow without resorting to Kubernetes like environments. The Git repository that you are using to provision the services must have at least one (sub)directory for each service. Gitopper will install packages if told to do so. It will not upgrade or downgrade them, assuming there is a better way of doing those. The remote interface of gitopper uses SSH keys for authentication, this hopefully helps to fit in, in a sysadmin organisation. The following features are implemented: - *Metrics*: are included see below, they export a Git hash, so a rollout can be tracked. - *Diff detection*: possible using the metrics or gitopperctl. - *Out of band rollbacks*: use gitopperctl to bypass the normal Git workflow. - *No client side processing*: files are used as they are in the Git repo. - *Canarying*: give a service a different branch to check out. The options are: **-h, --hosts strings** : hosts (comma separated) to impersonate, local hostname is always added **-c, --config string** : config file to read **-s, --ssh string** : ssh address to listen on (default ":2222") **-m, --metric string** : http metrics address to listen on (default ":9222") **-d, --debug** : enable debug logging **-r, --restart** : send SIGHUP to ourselves when config changes **-o, --root** : require root permission, setting to false can aid in debugging (default true) **-t, --duration duration** : default duration between pulls (default 5m0s) For bootstrapping gitopper itself the following options are available: **-U, --upstream string** : use this git repo to clone and to bootstrap from **-D, --directory string** : directory to sparse checkout (default "gitopper") **-B, --branch string** : check out in this branch (default "main") **-M, --mount string** : check out into this directory, -c is relative to this directory **-P, --pull** : pull (update) the git repo to the newest version before starting ## Quick Start - Generate a toy SSH key: `ssh-keygen -t ed25519` and make it write to an `id_ed25519_gitopper` file. - Put the path to the *PUBLIC* key (ending in .pub) in the `keys` fields of `[global]` of the following config.toml file. - Start as root: `sudo ./gitopper -c config.toml -h localhost` ~~~ toml [global] upstream = "https://github.com/miekg/gitopper-config" branch = "main" mount = "/tmp/gitopper" keys = [ { path = "keys/id_ed25519_gitopper.pub", ro = true }, ] [[services]] machine = "localhost" service = "prometheus" user = "prometheus" package = "prometheus" action = "reload" dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, ] ~~~ And things should work then. I.e. in /etc/prometheus you should see the content of the *miekg/gitopper-config* repository. Note that the prometheus package is installed, because `package` is mentioned in the config file. The checked out git repo in /tmp/prometheus should _only_ contain the prometheus directory thanks to the sparse checkout. Changes made to any other subdirectory in that repo do not trigger a prometheus reload. Then with gitopperctl(8) you can query the server: ~~~ ./gitopperctl -i <path-to-your-key> list service @localhost # SERVICE HASH STATE INFO CHANGED 0 prometheus 606eb576 OK Fri, 18 Nov 2022 09:14:52 UTC ~~~ ## Services A service can be in 5 states: OK, FREEZE, ROLLBACK (which is a FREEZE to a previous commit) and BROKEN/DIFF. These states are not carried over when gitopper crashes/stops (maybe we want this to be persistent, would be nice to have this state in the git repo somehow?). * `OK`: everything is running and we're tracking upstream. * `FREEZE`: everything is running, but we're not tracking upstream. * `ROLLBACK`: everything is running, but we're not tracking upstream *and* we're pinned to an older commit. This state is quickly followed by FREEZE if we were successful rolling back, otherwise BROKEN (systemd error) of DIFF (git error) * `BROKEN`: something with the service is broken, we're still tracking upstream. I.e. systemd error. * `DIFF`: the git repository can't be reconciled with upstream. I.e. git error. ROLLBACK is a transient state and quickly moves to FREEZE, unless something goes wrong then it becomes BROKEN, or DIFF depending on what goes wrong (systemd, or git respectively). ~~~ +-------------------------+ | | v | *OK -------> ROLLBACK ---> FREEZE | / \ | | / \ v | | | | | v v | | BROKEN DIFF | | | | | | | | | +--------+---------+------+ ~~~ - `*OK` is the start state - from `OK` and `FREEZE` we can still end up in `BROKEN` and `FREEZE` and vice versa. ## Config File ~~~ toml # global options are applied if a service doesn't list them [global] upstream = "https://github.com/miekg/gitopper-config" # repository where to download from mount = "/tmp" # directory where to download to, mount+service is used as path # ssh keys that are allowed in via authorized keys keys =[ { path = "keys/miek_id_ed25519_gitopper.pub" }, { path = "keys/another_key.pub", ro = true }, ] # each managed service has an entry like this [[services]] machine = "prometheus" # hostname of the machine, so a host knows when to pick this up. service = "prometheus" # service identifier, if it's used by systemd it must be the systemd service name action = "reload" # call systemctl <action> <service> when the git repo changes, may be empty branch = "main" # what branch to check out package = "prometheus" # as used by package mgmt, may be empty (not implemented yet) user = "prometheus" # do the check out with this user # what directories or files from the repo to mount under the local directories dirs = [ { local = "/etc/prometheus", link = "prometheus/etc" }, # prometheus/etc *in the repo* should be mounted under /etc/prometheus { local = "/etc/caddy/Caddyfile", link = "caddy/etc/Caddyfile", file = true }, # caddy/etc/Caddyfile *in the repo* should be mounted under /etc/caddy/Caddyfile ] ~~~ Note that `machine` above should match either the machine name ($HOSTNAME) or any of the values you give on the `-h` flag. This allows you to create services that run everywhere, by defining a service that have name (say) "localhost" and then deploying gitopper with `-h localhost` on every machine. Options for each service: - `machine`: the machine where this service should be active. By default `gitopper` will know the current hostname, but multiple aliases may be given to it via the `-h` flag. - `service`: what systemd unit file is used to call `action` on. If service contains an `@` a service template unit is assumed and gitopper will then run `systemctl enable <service>` to enable the service template. - `action`: action to use when calling `systemctl <service>`. If empty no systemd command will be issued when the repo changes. - `branch`: what branch to use in the checked out repo. Note different branches that use the *same* repository on disk, will error on startup. - `package`: what package to install for this service. If empty, no package will be installed. - `user`: what user should the git repository belong to. - `dirs`: describe the mapping between directories and files in the repository and on the local disk. `local` is the *on disk* name, and `link` is the *relative* path of the directory or file in the git repo. If a single file is used, `file` should be set to true. ### How to Break It Moving to a new user, will break git pull, with an error like 'dubious ownership of repository'. If you want a different owner for a service, it's best to change the mount as well so you get a new repo. Gitopper is currently not smart enough to detect this and fix things on the fly. ## Interface Gitopper opens two ports: 9222 for metrics and 2222 for the rest-protocol-over-SSH. For any interaction with gitopper over this port your key must be configured for it. The following services are implemented: * List all defined machines. * List services run on the machine. * List a specific service. * Freeze a service to the current git commit. * Unfreeze a service, i.e. to let it pull again. * Rollback a service to a specific commit. For each of these gitopperctl(8) will execute a "command" and will parse the returned JSON into a nice table. ## Metrics The following metrics are exported: * gitopper_service_state{"service"} \<state\> * gitopper_service_change_time_seconds{"service"} \<epoch\> * gitopper_machine_git_errors_total - total number of errors when running git. * gitopper_machine_git_ops_total - total number of git runs. Metrics are available under the /metrics endpoint on port 9222. ## Exit Code Gitopper has following exit codes: 0 - normal exit 2 - SIGHUP seen (signal to systemd to restart us) ## Bootstrapping There are a couple of options that allow gitopper to bootstrap itself *and* make gitopper to be managed by gitopper. Basically those options allow you to specify a service on the command line. Gitopper will check out the repo and then proceed to read the config *in that repo* and setup everything from there. I.e.: ~~~ ... -c config.toml -U https://github.com/miekg/gitopper-config -D gitopper -M /tmp/ ~~~ Will sparse check out (only the `gitopper` (-D flag) directory) of the repo *gitopper-config* (-U flag) in /tmp/gitopper (-M flag, internally '/gitopper' is added) and will then proceed to parse the config file /tmp/gitopper/gitopper/config.toml and proceed with a normal startup. Note this setup implies that you *must* place config.toml *inside* a `gitopper` directory, just as the other services must have their own subdirectories, gitopper needs one too. The gitopper service self is *also* added to the managed services which you can inspect with gitopperctl(8). Any keys that have *relative* paths, will also be changed to key inside this Git managed directory and pick up keys *from that repo*. The `-P` flag can be given to pull the repository even if it already exists, sometimes you need to the newest version to properly bootstrap. For normal services the "git pull" routine will automatically rectify it and restart the service. ## See Also See [this design doc](https://miek.nl/2022/november/15/provisioning-services/), and gitopperctl(8). 0707010000001D000081A400000000000000000000000166CD61670000010B000000000000000000000000000000000000002100000000gitopper-0.0.20/gitopper.service[Unit] Description=Gitopper Gitops Documentation=https://github.com/miekg/gitopper After=network.target [Service] ExecStart=/usr/bin/gitopper -c /etc/gitopper/config.toml ExecReload=/bin/kill -SIGHUP $MAINPID Restart=on-failure [Install] WantedBy=multi-user.target 0707010000001E000081A400000000000000000000000166CD616700000461000000000000000000000000000000000000001700000000gitopper-0.0.20/go.modmodule github.com/miekg/gitopper go 1.19 require ( github.com/gliderlabs/ssh v0.3.7 github.com/pelletier/go-toml/v2 v2.2.2 github.com/prometheus/client_golang v1.19.1 github.com/rodaine/table v1.0.1 github.com/spf13/pflag v1.0.5 github.com/urfave/cli/v2 v2.23.5 go.science.ru.nl v0.0.65 golang.org/x/crypto v0.25.0 ) require github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 require github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/google/go-cmp v0.6.0 github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/sys v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect ) 0707010000001F000081A400000000000000000000000166CD616700001737000000000000000000000000000000000000001700000000gitopper-0.0.20/go.sumgithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ= github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw= github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= go.science.ru.nl v0.0.65 h1:QzyxBQ1HyyJGAPQU9HOwg5AENqTagQcbcqfVQ0stvOY= go.science.ru.nl v0.0.65/go.mod h1:IURN/hfo7UAviudnjTgunIM9GAYLIRpyGyh8W+8NKFQ= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 07070100000020000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001500000000gitopper-0.0.20/keys07070100000021000081A400000000000000000000000166CD61670000005C000000000000000000000000000000000000003200000000gitopper-0.0.20/keys/miek_id_ed25519_gitopper.pubssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBuY4joKsf+jRdWemZyaHblLSf28tRl/GysmMOagpewQ miek@limos 07070100000022000081A400000000000000000000000166CD616700002AE9000000000000000000000000000000000000001800000000gitopper-0.0.20/main.gopackage main import ( "context" "errors" "fmt" "io/ioutil" "net" "net/http" "os" "os/signal" "path" "strings" "sync" "syscall" "time" "github.com/gliderlabs/ssh" "github.com/miekg/gitopper/ospkg" "github.com/miekg/gitopper/osutil" "github.com/prometheus/client_golang/prometheus/promhttp" flag "github.com/spf13/pflag" "go.science.ru.nl/log" ) type ExecContext struct { // Configuration Hosts []string ConfigSource string SAddr string MAddr string Debug bool Restart bool Root bool Duration time.Duration Upstream string Dir string Branch string Mount string Pull bool // Runtime State HTTPMux *http.ServeMux } func (exec *ExecContext) RegisterFlags(fs *flag.FlagSet) { if fs == nil { fs = flag.CommandLine } fs.SortFlags = false fs.StringSliceVarP(&exec.Hosts, "hosts", "h", []string{osutil.Hostname()}, "hosts (comma separated) to impersonate, hostname is always added") fs.StringVarP(&exec.ConfigSource, "config", "c", "", "config file to read") fs.StringVarP(&exec.SAddr, "ssh", "s", ":2222", "ssh address to listen on") fs.StringVarP(&exec.MAddr, "metric", "m", ":9222", "http metrics address to listen on") fs.BoolVarP(&exec.Debug, "debug", "d", false, "enable debug logging") fs.BoolVarP(&exec.Restart, "restart", "r", false, "send SIGHUP when config changes") fs.BoolVarP(&exec.Root, "root", "o", true, "require root permission, setting to false can aid in debugging") fs.DurationVarP(&exec.Duration, "duration", "t", 5*time.Minute, "default duration between pulls") // bootstrap flags fs.StringVarP(&exec.Upstream, "upstream", "U", "", "[bootstrapping] use this git repo") fs.StringVarP(&exec.Dir, "directory", "D", "gitopper", "[bootstrapping] directory to sparse checkout") fs.StringVarP(&exec.Branch, "branch", "B", "main", "[bootstrapping] check out in this branch") fs.StringVarP(&exec.Mount, "mount", "M", "", "[bootstrapping] check out into this directory, -c is relative to this directory") fs.BoolVarP(&exec.Pull, "pull", "P", false, "[bootstrapping] pull (update) the git repo to the newest version before starting") } var ( ErrNotRoot = errors.New("not root") ErrNoConfig = errors.New("-c flag is mandatory") ErrHUP = errors.New("hangup requested") ) type RepoPullError struct { Machine string Upstream string Underlying error } func (err *RepoPullError) Error() string { return fmt.Sprintf("Machine %q, error pulling repo %q: %s", err.Machine, err.Upstream, err.Underlying) } func (err *RepoPullError) Unwrap() error { if err == nil { return nil } return err.Underlying } func serveMonitoring(exec *ExecContext, controllerWG, workerWG *sync.WaitGroup) error { exec.HTTPMux.Handle("/metrics", promhttp.Handler()) ln, err := net.Listen("tcp", exec.MAddr) if err != nil { return err } srv := &http.Server{ Addr: exec.MAddr, Handler: exec.HTTPMux, } controllerWG.Add(1) // Ensure HTTP server draining blocks application shutdown. go func() { defer controllerWG.Done() workerWG.Wait() // Unblocks upon context cancellation and workers finishing. srv.Shutdown(context.TODO()) // TODO: Derive context tree more carefully from root. }() controllerWG.Add(1) go func() { defer controllerWG.Done() err := srv.Serve(ln) switch { case err == nil: case errors.Is(err, http.ErrServerClosed): default: log.Fatal(err) } }() return nil } func serveSSH(exec *ExecContext, controllerWG, workerWG *sync.WaitGroup, allowed []*Key, sshHandler ssh.Handler) error { l, err := net.Listen("tcp", exec.SAddr) if err != nil { return err } srv := &ssh.Server{Addr: exec.SAddr, Handler: sshHandler} srv.SetOption(ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { for _, a := range allowed { if ssh.KeysEqual(a.PublicKey, key) { log.Infof("Granting access for user %q with public key %q", ctx.User(), a.Path) return true } } log.Warningf("No valid keys found for user %q", ctx.User()) return false })) controllerWG.Add(1) // Ensure SSH server draining blocks application shutdown. go func() { defer controllerWG.Done() workerWG.Wait() // Unblocks upon context cancellation and workers finishing. srv.Shutdown(context.TODO()) // TODO: Derive context tree more carefully from root. }() controllerWG.Add(1) go func() { defer controllerWG.Done() err := srv.Serve(l) switch { case err == nil: case errors.Is(err, ssh.ErrServerClosed): default: log.Fatal(err) } }() return nil } func run(exec *ExecContext) error { if os.Geteuid() != 0 && exec.Root { return ErrNotRoot } if exec.Debug { log.D.Set() } if exec.ConfigSource == "" { return ErrNoConfig } // bootstrapping self := selfService(exec.Upstream, exec.Branch, exec.Mount, exec.Dir) if self != nil { log.Infof("Bootstrapping from repo %q and adding service %q for %q", exec.Upstream, self.Service, self.Machine) gc := self.newGitCmd() err := gc.Checkout() if err != nil { return &RepoPullError{self.Machine, self.Upstream, err} } if exec.Pull { if _, err := gc.Pull(); err != nil { // don't exit here, we have a repo, maybe it's good enough, we can always pull later log.Warningf("Bootstrapping service %q, error pulling repo %q: %s, continuing", self.Service, self.Upstream, err) } } exec.ConfigSource = path.Join(path.Join(path.Join(self.Mount, self.Service), exec.Dir), exec.ConfigSource) log.Infof("Setting config to %s", exec.ConfigSource) } doc, err := os.ReadFile(exec.ConfigSource) if err != nil { return fmt.Errorf("reading config: %v", err) } c, err := parseConfig(doc) if err != nil { return fmt.Errorf("parsing config: %v", err) } if err := c.Valid(); err != nil { return fmt.Errorf("validating config: %v", err) } if self != nil { c.Services = append(c.Services, self) } for _, k := range c.Global.Keys { if !path.IsAbs(k.Path) && self != nil { // bootstrapping newpath := path.Join(path.Join(path.Join(self.Mount, self.Service), exec.Dir), k.Path) k.Path = newpath } log.Infof("Reading public key %q", k.Path) data, err := ioutil.ReadFile(k.Path) if err != nil { return err } a, _, _, _, err := ssh.ParseAuthorizedKey(data) if err != nil { return err } k.PublicKey = a } ctx, cancel := context.WithCancel(context.TODO()) defer cancel() var workerWG, controllerWG sync.WaitGroup defer controllerWG.Wait() // start a fake worker thread, that in the case of no actual threads, will call done on the workerWG (and more // importantly will now have seen at least one Add(1)). This will make sure the serveMetrics and serveSSH return // correctly on receiving ^C. workerWG.Add(1) go func() { defer workerWG.Done() select { case <-ctx.Done(): return } }() pkg := ospkg.New() servCnt := 0 hostServices := map[string]struct{}{} // we can't have duplicate service name on a single machine. for _, serv := range c.Services { if !serv.forMe(exec.Hosts) { continue } if _, ok := hostServices[serv.Service]; ok { log.Fatalf("Service %q has a duplicate on these machines %v", serv.Service, exec.Hosts) } hostServices[serv.Service] = struct{}{} servCnt++ s := serv.merge(c.Global) log.Infof("Service %q with upstream %q", s.Service, s.Upstream) gc := s.newGitCmd() if s.Package != "" { if err := pkg.Install(s.Package); err != nil { log.Fatalf("Service %q, error installing package %q: %s", s.Service, s.Package, err) } } // Initial checkout - if needed. err := gc.Checkout() if err != nil { log.Warningf("Service %q, error pulling repo %q: %s", s.Service, s.Upstream, err) s.SetState(StateDiff, fmt.Sprintf("error pulling %q: %s", s.Upstream, err)) continue } log.Infof("Service %q, repository in %q with %q", s.Service, gc.Repo(), gc.Hash()) // all succesfully done, do the bind mounts and start our puller mounts, err := s.bindmount() if err != nil { log.Warningf("Service %q, error setting up bind mounts for %q: %s", s.Service, s.Upstream, err) s.SetState(StateBroken, fmt.Sprintf("error setting up bind mounts repo %q: %s", s.Upstream, err)) continue } if strings.Contains(s.Service, "@") { if err := s.enable(); err != nil { log.Fatalf("Service %q, error enabling instance template: %s", s.Service, err) } } // Restart any services as they see new files in their bindmounts. Do this here, because we can't be // sure there is an update to a newer commit that would also kick off a restart. if mounts > 0 { if rerr := s.reload(); rerr != nil { log.Warningf("Service %q, error running systemctl daemon-reload: %s", s.Service, rerr) s.SetState(StateBroken, fmt.Sprintf("error running systemctl daemon-reload %q: %s", s.Upstream, rerr)) } else if err := s.start(); err != nil { log.Warningf("Service %q, error running systemctl start: %s", s.Service, err) s.SetState(StateBroken, fmt.Sprintf("error running systemctl start %q: %s", s.Upstream, err)) // no continue; maybe git pull will make this work later } else { s.SetState(StateOK, "") } } else { s.SetState(StateOK, "") } workerWG.Add(1) go func() { defer workerWG.Done() s.trackUpstream(ctx, exec.Duration) }() } if servCnt == 0 { log.Warningf("No services found for machine: %v, exiting", exec.Hosts) return nil } sshHandler := newRouter(c, exec.Hosts) if err := serveSSH(exec, &controllerWG, &workerWG, c.Global.Keys, sshHandler); err != nil { return err } if err := serveMonitoring(exec, &controllerWG, &workerWG); err != nil { return err } log.Infof("Launched servers on port %s (ssh) and %s (metrics) for machines: %v, %d public keys loaded", exec.SAddr, exec.MAddr, exec.Hosts, len(c.Global.Keys)) done := make(chan os.Signal, 1) signal.Notify(done, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) if exec.Restart { workerWG.Add(1) go func() { defer workerWG.Done() trackConfig(ctx, exec.ConfigSource, done) }() } hup := make(chan struct{}) workerWG.Add(1) go func() { defer workerWG.Done() select { case s := <-done: cancel() if s == syscall.SIGHUP { close(hup) } case <-ctx.Done(): } }() workerWG.Wait() select { case <-hup: return ErrHUP default: } return nil } func main() { exec := ExecContext{HTTPMux: http.NewServeMux()} exec.RegisterFlags(nil) flag.Parse() flag.VisitAll(func(f *flag.Flag) { // add hostname if not already there if f.Name != "hosts" { return } ok := false hostname := osutil.Hostname() for _, v := range f.Value.(flag.SliceValue).GetSlice() { if v == hostname { ok = true break } } if !ok { f.Value.Set(osutil.Hostname()) } }) err := run(&exec) switch { case err == nil: case errors.Is(err, ErrHUP): // on HUP exit with exit status 2, so systemd can restart us (Restart=OnFailure) os.Exit(2) default: log.Fatal(err) } } 07070100000023000081A400000000000000000000000166CD616700000C7B000000000000000000000000000000000000001D00000000gitopper-0.0.20/main_test.gopackage main import ( "errors" "fmt" "net/http" "sync" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/miekg/gitopper/osutil" "github.com/phayes/freeport" flag "github.com/spf13/pflag" ) func TestFlags(t *testing.T) { for _, test := range []struct { Arguments []string Want ExecContext }{ { Arguments: []string(nil), Want: ExecContext{ Hosts: []string{osutil.Hostname()}, // default value ConfigSource: "", SAddr: ":2222", MAddr: ":9222", Debug: false, Restart: false, Root: true, Duration: 5 * time.Minute, Upstream: "", Dir: "gitopper", Branch: "main", Mount: "", }, }, { Arguments: []string{ "-h=me,you", "-c=/dev/null", "-s=:3000", "-m=:2000", "-d", "-r", "-U=/upstream", "-D=/sparse", "-B=branch", "-M=checkout", }, Want: ExecContext{ Hosts: []string{"me", "you"}, // only in main() we add our hostname ConfigSource: "/dev/null", SAddr: ":3000", MAddr: ":2000", Debug: true, Restart: true, Root: true, Duration: 5 * time.Minute, Upstream: "/upstream", Dir: "/sparse", Branch: "branch", Mount: "checkout", }, }, } { fs := flag.NewFlagSet("", flag.ContinueOnError) var exec ExecContext exec.RegisterFlags(fs) if err := fs.Parse(test.Arguments); err != nil { t.Fatalf("fs.Parse(%v) = %v, want %v", test.Arguments, err, error(nil)) } if diff := cmp.Diff(exec, test.Want); diff != "" { t.Errorf("after parsing %v, exec = %v, want %v\n\ndiff:\n\n%v", test.Arguments, exec, test.Want, diff) } } } func TestEndToEnd(t *testing.T) { // TODO: Make generally testable. err := run(&ExecContext{Root: true}) if got, want := err, ErrNotRoot; !errors.Is(got, want) { t.Errorf("run(exec) = %v, want %v", got, want) } } func port(t *testing.T) int { t.Helper() p, err := freeport.GetFreePort() if err != nil { t.Fatalf("acquire port: %v", err) } return p } // httpClient returns a pristine HTTP client that does not use the shared // connection cache. Shared connection caches have produced flaky tests in the // past. func httpClient() *http.Client { return &http.Client{Transport: new(http.Transport)} } func TestServeMonitoring(t *testing.T) { p := port(t) exec := &ExecContext{ MAddr: fmt.Sprintf(":%v", p), HTTPMux: http.NewServeMux(), } var controllerWG, workerWG sync.WaitGroup workerWG.Add(1) if err := serveMonitoring(exec, &controllerWG, &workerWG); err != nil { t.Fatalf("serveMonitoring(exec, &controllerWG, &workerWG) = %v, want %v", err, error(nil)) } client := httpClient() resp, err := client.Get(fmt.Sprintf("http://localhost:%v/metrics", p)) if err != nil { t.Fatalf(`client.Get("http://localhost:%v/metrics") err = %v, want %v`, p, err, error(nil)) } if got, want := resp.StatusCode, http.StatusOK; got != want { t.Errorf("after HTTP GET, resp.StatusCode = %v, want %v", got, want) } t.Log("Stopping workers; should unlock controllerWG") workerWG.Done() controllerWG.Wait() } 07070100000024000081A400000000000000000000000166CD616700000281000000000000000000000000000000000000001B00000000gitopper-0.0.20/metrics.gopackage main import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // do we have a latecy that we can track? var ( metricServiceState = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "gitopper", Subsystem: "service", Name: "state", Help: "Current state for this service.", }, []string{"service"}) metricServiceTimestamp = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "gitopper", Subsystem: "service", Name: "change_time_seconds", Help: "Timestamp for last state change for this service.", }, []string{"service"}) ) 07070100000025000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001600000000gitopper-0.0.20/ospkg07070100000026000081A400000000000000000000000166CD616700000225000000000000000000000000000000000000002300000000gitopper-0.0.20/ospkg/archlinux.gopackage ospkg import ( "os/exec" "go.science.ru.nl/log" ) // ArchLinuxInstaller installs packages on Arch Linux. type ArchLinuxInstaller struct{} var _ Installer = (*ArchLinuxInstaller)(nil) const pacmanCommand = "/usr/bin/pacman" func (p *ArchLinuxInstaller) Install(pkg string) error { installCmd := exec.Command(pacmanCommand, "-S", "--noconfirm", pkg) out, err := installCmd.CombinedOutput() if err != nil { log.Warningf("Install failed: %s", out) } else { log.Infof("Already installed or re-installed %q", pkg) } return err } 07070100000027000081A400000000000000000000000166CD6167000002B4000000000000000000000000000000000000002000000000gitopper-0.0.20/ospkg/debian.gopackage ospkg import ( "os" "os/exec" "go.science.ru.nl/log" ) // DebianInstaller installs packages on Debian/Ubuntu. type DebianInstaller struct{} var _ Installer = (*DebianInstaller)(nil) const aptGetCommand = "/usr/bin/apt-get" func (p *DebianInstaller) Install(pkg string) error { args := []string{"-qq", "--assume-yes", "--no-install-recommends", "install", pkg} installCmd := exec.Command(aptGetCommand, args...) installCmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive", ) out, err := installCmd.CombinedOutput() if err != nil { log.Warningf("Install failed: %s", out) } else { log.Infof("Already installed or re-installed %q", pkg) } return err } 07070100000028000081A400000000000000000000000166CD616700000225000000000000000000000000000000000000002300000000gitopper-0.0.20/ospkg/installer.gopackage ospkg import ( "github.com/miekg/gitopper/osutil" "go.science.ru.nl/log" ) // Installer represents OS package installation tool. type Installer interface { Install(pkg string) error } // New returns an Installer suited for the current system, or the NoopInstaller when none are found. func New() Installer { switch osutil.ID() { case "debian", "ubuntu": return new(DebianInstaller) case "arch": return new(ArchLinuxInstaller) } log.Warningf("Returning Noop package installer for %s", osutil.ID()) return new(NoopInstaller) } 07070100000029000081A400000000000000000000000166CD616700000166000000000000000000000000000000000000001E00000000gitopper-0.0.20/ospkg/noop.gopackage ospkg // NoopManager implements a no-op of the Installer interface. // Its purpose is to enable scenarios where no package handling is required, // i.e. the necessary executables are already available on the host. type NoopInstaller struct{} var _ Installer = (*NoopInstaller)(nil) func (p *NoopInstaller) Install(pkg string) error { return nil } 0707010000002A000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001700000000gitopper-0.0.20/osutil0707010000002B000081A400000000000000000000000166CD6167000000E7000000000000000000000000000000000000002300000000gitopper-0.0.20/osutil/hostname.gopackage osutil import "os" // Hostname returns the hostname of this host. If it fails the value $HOSTNAME is returned. func Hostname() string { h, err := os.Hostname() if err != nil { h = os.Getenv("HOSTNAME") } return h } 0707010000002C000081A400000000000000000000000166CD616700000267000000000000000000000000000000000000002200000000gitopper-0.0.20/osutil/release.gopackage osutil import ( "bytes" "os" ) var ( // this is a variable so it can be overridden during unit-testing. osRelease = "/etc/os-release" ) // ID returns the ID of the system as specific in the osRelease file. func ID() string { buf, err := os.ReadFile(osRelease) if err != nil { return "" } i := bytes.Index(buf, []byte("\nID=")) // want ^ID= if i == 0 { return "" } id := buf[i+len("\nID="):] j := bytes.Index(id, []byte("\n")) if j == 0 { return "" } // Some attributes are quoted, some are not. Cover both. id = bytes.ReplaceAll(id[:j], []byte("\""), []byte{}) return string(id) } 0707010000002D000081A400000000000000000000000166CD6167000001FC000000000000000000000000000000000000002700000000gitopper-0.0.20/osutil/release_test.gopackage osutil import ( "testing" ) func TestID(t *testing.T) { var tests = []struct { osReleaseFilePath, expected string }{ { osReleaseFilePath: "testdata/os-release-rhel77", expected: "rhel", }, { osReleaseFilePath: "testdata/os-release-ubuntu2004", expected: "ubuntu", }, } for _, test := range tests { osRelease = test.osReleaseFilePath actual := ID() if test.expected != actual { t.Fatalf("Expected: %q, got :%q", test.expected, actual) } } } 0707010000002E000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000002000000000gitopper-0.0.20/osutil/testdata0707010000002F000081A400000000000000000000000166CD616700000213000000000000000000000000000000000000003200000000gitopper-0.0.20/osutil/testdata/os-release-rhel77NAME="Red Hat Enterprise Linux Server" VERSION="7.7 (Maipo)" ID="rhel" ID_LIKE="fedora" VARIANT="Server" VARIANT_ID="server" VERSION_ID="7.7" PRETTY_NAME="Red Hat Enterprise Linux Server 7.7 (Maipo)" ANSI_COLOR="0;31" CPE_NAME="cpe:/o:redhat:enterprise_linux:7.7:GA:server" HOME_URL="https://www.redhat.com/" BUG_REPORT_URL="https://bugzilla.redhat.com/" REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 7" REDHAT_BUGZILLA_PRODUCT_VERSION=7.7 REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" REDHAT_SUPPORT_PRODUCT_VERSION="7.7"07070100000030000081A400000000000000000000000166CD61670000017D000000000000000000000000000000000000003600000000gitopper-0.0.20/osutil/testdata/os-release-ubuntu2004NAME="Ubuntu" VERSION="20.04.1 LTS (Focal Fossa)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 20.04.1 LTS" VERSION_ID="20.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=focal UBUNTU_CODENAME=focal07070100000031000081A400000000000000000000000166CD61670000016C000000000000000000000000000000000000001F00000000gitopper-0.0.20/osutil/user.gopackage osutil import ( "os/user" "strconv" ) // User looks up the username u and return the uid and gid. If the username can't be found 0, 0 is returned. func User(u string) (int64, int64) { u1, err := user.Lookup(u) if err != nil { return 0, 0 } uid, _ := strconv.ParseInt(u1.Uid, 10, 32) gid, _ := strconv.ParseInt(u1.Gid, 10, 32) return uid, gid } 07070100000032000041ED00000000000000000000000266CD616700000000000000000000000000000000000000000000001600000000gitopper-0.0.20/proto07070100000033000081A400000000000000000000000166CD616700000283000000000000000000000000000000000000001F00000000gitopper-0.0.20/proto/proto.go// Package proto holds the structures that return the json to the client. package proto type ( ListMachines struct { ListMachines []ListMachine `json:"machines"` } ListMachine struct { Machine string `json:"machine"` // Machine as set in config file. Actual string `json:"actual"` // Actual machine responding (i.e. -h flag might be used) } ListServices struct { ListServices []ListService `json:"services"` } ListService struct { Service string `json:"service"` Hash string `json:"hash"` State string `json:"state"` StateInfo string `json:"stateinfo"` StateChange string `json:"change"` } ) 07070100000034000081A400000000000000000000000166CD616700002FAD000000000000000000000000000000000000001A00000000gitopper-0.0.20/server.gopackage main import ( "bytes" "context" "fmt" "math/rand" "os" "os/exec" "path" "strings" "sync" "time" "github.com/miekg/gitopper/gitcmd" "github.com/miekg/gitopper/osutil" "go.science.ru.nl/log" "go.science.ru.nl/mountinfo" ) // Service contains the service configuration tied to a specific machine. type Service struct { Upstream string // The URL of the (upstream) Git repository. Branch string // The branch to track (defaults to 'main'). Service string // Identifier for the service - will be used for action. Machine string // Identifier for this machine - may be shared with multiple machines. Package string // The package that might need installing. User string // what user to use for checking out the repo. Action string // The systemd action to take when files have changed. Mount string // Concatenated with server.Service this will be the directory where the git repo is checked out. Dirs []Dir // How to map our local directories to the git repository. pullNow chan struct{} // do an on demand pull mu sync.RWMutex state State stateInfo string // Extra info some states carry. stateStamp time.Time // When did state change (UTC). hash string // Git hash of the current git checkout. } type Dir struct { Local string // The directory on the local filesystem. Link string // The subdirectory inside the git repo to map to. File bool // If true Local and Link are considered files. } // Current State of a service. type State int const ( StateOK State = iota // The service is running as it should. StateFreeze // The service is locked to the current commit, no further updates are done. StateRollback // The service is rolled back and locked to that commit, no further updates are done. StateBroken // The service is broken, i.e. didn't start, systemctl error, etc. StateDiff // The service's git repo can't be reconciled with upstream for some reason. ) func (s State) String() string { switch s { case StateOK: return "OK" case StateFreeze: return "FREEZE" case StateRollback: return "ROLLBACK" case StateBroken: return "BROKEN" case StateDiff: return "DIFF" } return "" } func (s *Service) State() (State, string) { s.mu.RLock() defer s.mu.RUnlock() return s.state, s.stateInfo } func (s *Service) SetState(st State, info string) { log.Infof("Service %q, setting to state: %s:s", s.Service, st) s.mu.Lock() defer s.mu.Unlock() s.stateStamp = time.Now().UTC() s.state = st s.stateInfo = info metricServiceState.WithLabelValues(s.Service).Set(float64(s.state)) metricServiceTimestamp.WithLabelValues(s.Service).Set(float64(s.stateStamp.Unix())) } func (s *Service) Hash() string { s.mu.RLock() defer s.mu.RUnlock() return s.hash } func (s *Service) SetHash(h string) { s.mu.Lock() defer s.mu.Unlock() s.hash = h } func (s *Service) Change() time.Time { s.mu.RLock() defer s.mu.RUnlock() return s.stateStamp } func (s *Service) signalPullNow() { s.pullNow <- struct{}{} } // merge merges anything defined in global into s when s doesn't specify it and returns the new Service. func (s *Service) merge(global Global) *Service { if s.Upstream == "" { s.Upstream = global.Upstream } if s.Mount == "" { s.Mount = global.Mount } if s.Branch == "" { s.Branch = global.Branch } if s.Branch == "" { s.Branch = "main" } // TODO: Examine whether replacing pullNow needs to occur with synchronization due to reads. s.pullNow = make(chan struct{}) // TODO(miek): newService would be a better place for time. return s } // forMe compares the hostnames with the service machine name, it there is a match for service is for us. func (s *Service) forMe(hostnames []string) bool { for _, h := range hostnames { if h == s.Machine { return true } } return false } func (s *Service) newGitCmd() *gitcmd.Git { dirs := []string{} for _, d := range s.Dirs { dirs = append(dirs, d.Link) } return gitcmd.New(s.Upstream, s.Branch, path.Join(s.Mount, s.Service), s.User, dirs) } // TrackUpstream does all the administration to track upstream and issue systemctl commands to keep the process // informed. func (s *Service) trackUpstream(ctx context.Context, duration time.Duration) { gc := s.newGitCmd() log.Infof("Launched tracking routine for %q", s.Service) s.SetHash(gc.Hash()) s.SetBoot() state, info := s.State() s.SetState(state, info) for { s.SetHash(gc.Hash()) select { case <-time.After(jitter(duration)): case <-s.pullNow: case <-ctx.Done(): return } // this in now only done once... because we set state to broken... Should we keep trying?? if state == StateRollback && info != s.hash { if err := gc.Rollback(info); err != nil { log.Warningf("Service %q, error rollback repo %q to %q: %s", s.Service, s.Upstream, info, err) s.SetState(StateDiff, fmt.Sprintf("error rolling back %q to %q: %s", s.Upstream, info, err)) continue } if _, err := s.bindmount(); err != nil { log.Warningf("Service %q, error setting up bind mounts for %q: %s", s.Service, s.Upstream, err) s.SetState(StateBroken, fmt.Sprintf("error setting up bind mounts repo %q: %s", s.Upstream, err)) continue } if rerr := s.reload(); rerr != nil { log.Warningf("Service %q, error running systemctl daemon-reload: %s", s.Service, rerr) s.SetState(StateBroken, fmt.Sprintf("error running systemctl daemon-reload %q: %s", s.Upstream, rerr)) continue } else if err := s.systemctl(); err != nil { log.Warningf("Service %q, error running systemctl: %s", s.Service, err) s.SetState(StateBroken, fmt.Sprintf("error running systemctl %q: %s", s.Upstream, err)) continue } log.Warningf("Service %q, successfully rollback repo %q to %s", s.Service, s.Upstream, info) s.SetState(StateFreeze, "ROLLBACK: "+info) continue } if state, _ := s.State(); state == StateFreeze || state == StateRollback { log.Warningf("Service %q is in %s, not pulling", s.Service, state) continue } changed, err := gc.Pull() if err != nil { log.Warningf("Service %q, error pulling repo %q: %s", s.Service, s.Upstream, err) s.SetState(StateDiff, fmt.Sprintf("error pulling %q: %s", s.Upstream, err)) continue } if !changed { continue } s.SetHash(gc.Hash()) state, info = s.State() s.SetState(state, info) if _, err := s.bindmount(); err != nil { log.Warningf("Service %q, error setting up bind mounts for %q: %s", s.Service, s.Upstream, err) s.SetState(StateBroken, fmt.Sprintf("error setting up bind mounts repo %q: %s", s.Upstream, err)) continue } log.Infof("Service %q, diff in repo %q, pinging it", s.Service, s.Upstream) if rerr := s.reload(); rerr != nil { log.Warningf("Service %q, error running systemctl daemon-reload: %s", s.Service, rerr) s.SetState(StateBroken, fmt.Sprintf("error running systemctl daemon-reload %q: %s", s.Upstream, rerr)) continue } else if err := s.systemctl(); err != nil { log.Warningf("Service %q, error running systemctl: %s", s.Service, err) s.SetState(StateBroken, fmt.Sprintf("error running systemctl %q: %s", s.Upstream, err)) continue } s.SetState(StateOK, "") } } func (s *Service) reload() error { ctx := context.TODO() cmd := exec.CommandContext(ctx, "systemctl", "daemon-reload") log.Infof("running %v", cmd.Args) return cmd.Run() } func (s *Service) systemctl() error { if s.Action == "" { return nil } ctx := context.TODO() cmd := exec.CommandContext(ctx, "systemctl", s.Action, s.Service) log.Infof("running %v", cmd.Args) return cmd.Run() } func (s *Service) enable() error { ctx := context.TODO() cmd := exec.CommandContext(ctx, "systemctl", "enable", s.Service) log.Infof("running %v", cmd.Args) return cmd.Run() } func (s *Service) start() error { ctx := context.TODO() cmd := exec.CommandContext(ctx, "systemctl", "start", s.Service) log.Infof("running %v", cmd.Args) return cmd.Run() } // Boot returns the start time of the service. If that isn't available because there isn't a Service in s, then we // return the kernel's boot time (i.e. when the system we started). func (s *Service) SetBoot() { ctx := context.TODO() cmd := &exec.Cmd{} if s.Service != "" { cmd = exec.CommandContext(ctx, "systemctl", "show", "--property=ExecMainStartTimestamp", s.Service) } else { cmd = exec.CommandContext(ctx, "systemctl", "show", "--property=KernelTimestamp") } log.Infof("running %v", cmd.Args) out, err := cmd.Output() if err != nil { return } out = bytes.TrimSpace(out) // Testing show this is the string returned: Mon 2022-11-21 09:39:59 CET, so parse that into a time.Time t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", string(out)) if err == nil { // on succes s.mu.Lock() defer s.mu.Unlock() s.stateStamp = t.UTC() } } // bindmount sets up the bind mount, the return integer returns how many mounts were performed. func (s *Service) bindmount() (int, error) { mounted := 0 for _, d := range s.Dirs { if d.Local == "" { continue } gitdir := path.Join(s.Mount, s.Service) gitdir = path.Join(gitdir, d.Link) logtype := "Directory" if d.File { logtype = "File" } if !exists(d.Local) { switch d.File { case false: if err := os.MkdirAll(d.Local, 0775); err != nil { log.Errorf("Directory %q can not be created", d.Local) return 0, fmt.Errorf("failed to create directory %q: %s", d.Local, err) } case true: if err := os.MkdirAll(path.Dir(d.Local), 0775); err != nil { log.Errorf("Directory %q can not be created", path.Dir(d.Local)) return 0, fmt.Errorf("failed to create directory %q: %s", path.Dir(d.Local), err) } file, err := os.Create(d.Local) if err != nil { log.Errorf("File %q, can not be created", d.Local) return 0, fmt.Errorf("failed to create file %q: %s", d.Local, err) } file.Close() } if os.Geteuid() == 0 { // set d.Local to the correct owner, if we are root uid, gid := osutil.User(s.User) if err := os.Chown(d.Local, int(uid), int(gid)); err != nil { log.Errorf("%s %q can not be chown-ed to %q: %s", logtype, d.Local, s.User, err) return 0, fmt.Errorf("failed to chown %s %q to %q: %s", strings.ToLower(logtype), d.Local, s.User, err) } } } if ok, err := mountinfo.Mounted(d.Local); err == nil && ok { if d.File == true { log.Infof("%s %q is already mounted, unmounting", logtype, d.Local) ctx := context.TODO() cmd := exec.CommandContext(ctx, "umount", d.Local) log.Infof("running %v", cmd.Args) err := cmd.Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { if e := exitError.ExitCode(); e != 0 { return 0, fmt.Errorf("failed to umount %q, exit code %d", d.Local, e) } } return 0, fmt.Errorf("failed to umount %q: %s", d.Local, err) } } else { log.Infof("%s %q is already mounted", logtype, d.Local) continue } } ctx := context.TODO() cmd := exec.CommandContext(ctx, "mount", "--bind", gitdir, d.Local) // mount needs to be r/w for pkg upgrades log.Infof("running %v", cmd.Args) err := cmd.Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { if e := exitError.ExitCode(); e != 0 { return 0, fmt.Errorf("failed to mount %q, exit code %d", gitdir, e) } } return 0, fmt.Errorf("failed to mount %q: %s", gitdir, err) } mounted++ } return mounted, nil } func selfService(upstream, branch, mount, dir string) *Service { if upstream == "" || branch == "" || mount == "" || dir == "" { return nil } return &Service{ Upstream: upstream, Branch: branch, Mount: mount, Action: "", // empty, because with -r true systemd will just restart us Service: "gitopper", Machine: osutil.Hostname(), Dirs: []Dir{{Link: dir}}, } } func exists(p string) bool { _, err := os.Stat(p) return err == nil } // jitter will add a random amount of jitter [0, d/2] to d. func jitter(d time.Duration) time.Duration { rand.Seed(time.Now().UnixNano()) max := d / 2 return d + time.Duration(rand.Int63n(int64(max))) } 07070100000035000081A400000000000000000000000166CD616700000299000000000000000000000000000000000000001F00000000gitopper-0.0.20/server_test.go//go:build root package main import ( "context" "os" "os/exec" "testing" ) func TestBindMounts(t *testing.T) { local, err := os.MkdirTemp(os.TempDir(), "") if err != nil { t.Fatal(err) } link, err := os.MkdirTemp(os.TempDir(), "") if err != nil { t.Fatal(err) } s := &Service{ User: "daemon", Dirs: []Dir{{ Local: local, Link: link, }}, } mounts, err := s.bindmount() if err != nil { t.Fatal(err) } defer func() { ctx := context.TODO() cmd := exec.CommandContext(ctx, "umount", s.Dirs[0].Link) t.Logf("running %v", cmd.Args) cmd.Run() }() if mounts != 1 { t.Fatalf("expected %d mounts, got %d", 1, mounts) } } 07070100000036000081A400000000000000000000000166CD616700000287000000000000000000000000000000000000001E00000000gitopper-0.0.20/setup_test.gopackage main import ( "os" "testing" "go.science.ru.nl/log" ) func TestInitialGitCheckout(t *testing.T) { log.Discard() temp, err := os.MkdirTemp(os.TempDir(), "") if err != nil { t.Fatalf("Failed to make temp dir: %q: %s", temp, err) } defer func() { os.RemoveAll(temp) }() s := Service{ Upstream: "https://github.com/miekg/gitopper-config", Branch: "main", Service: "test-service", Mount: temp, Dirs: []Dir{ {Link: "grafana/etc"}, {Link: "grafana/dashboards"}, }, } gc := s.newGitCmd() if err := gc.Checkout(); err != nil { t.Fatalf("Failed to checkout repo %q in %s: %s", s.Upstream, temp, err) } } 07070100000037000081A400000000000000000000000166CD61670000165C000000000000000000000000000000000000001700000000gitopper-0.0.20/ssh.gopackage main import ( "encoding/hex" "encoding/json" "io" "net/http" "strings" "time" "github.com/gliderlabs/ssh" "github.com/miekg/gitopper/osutil" "github.com/miekg/gitopper/proto" "go.science.ru.nl/log" ) func newRouter(c Config, hosts []string) ssh.Handler { return func(s ssh.Session) { pub := s.PublicKey() if pub == nil { log.Warningf("Connection denied for user %q", s.User()) io.WriteString(s, http.StatusText(http.StatusUnauthorized)) s.Exit(http.StatusUnauthorized) return } var key *Key for _, a := range c.Keys { if ssh.KeysEqual(a.PublicKey, s.PublicKey()) { // this should always have a hit, because it's already checked as an option in the ssh // server. key = a } } if len(s.Command()) == 0 { log.Warningf("No commands in connection for user %q", s.User()) io.WriteString(s, http.StatusText(http.StatusBadRequest)) s.Exit(http.StatusBadRequest) return } for prefix, f := range routes { if strings.HasPrefix(s.Command()[0], prefix) { if key.RO && strings.HasPrefix(s.Command()[0], "/state/") { log.Warningf("Key for user %q with public key %q is set RO and route is RW, denying", key.Path, s.User()) io.WriteString(s, http.StatusText(http.StatusUnauthorized)) s.Exit(http.StatusUnauthorized) return } log.Infof("Routing to %q for user %q with public key %q", prefix, s.User(), key.Path) f(c, s, hosts) return } } log.Warningf("No route found for user %q with public key %q", s.User(), key.Path) io.WriteString(s, http.StatusText(http.StatusNotFound)) s.Exit(http.StatusNotFound) } } var routes = map[string]func(Config, ssh.Session, []string){ "/list/machine": ListMachines, "/list/service": ListService, "/do/freeze": FreezeService, "/do/unfreeze": UnfreezeService, "/do/rollback": RollbackService, "/do/pull": PullService, } func writeAndExit(s ssh.Session, data []byte, err error) { if err != nil { io.WriteString(s, http.StatusText(http.StatusInternalServerError)) s.Exit(http.StatusInternalServerError) return } s.Write(data) s.Exit(0) } func ListMachines(c Config, s ssh.Session, _ []string) { lm := proto.ListMachines{ ListMachines: make([]proto.ListMachine, len(c.Services)), } for i, service := range c.Services { lm.ListMachines[i] = proto.ListMachine{ Machine: service.Machine, Actual: osutil.Hostname(), } } data, err := json.Marshal(lm) writeAndExit(s, data, err) } func ListService(c Config, s ssh.Session, hosts []string) { ls := proto.ListServices{ListServices: []proto.ListService{}} target := "" if len(s.Command()) > 1 { target = s.Command()[1] } for _, service := range c.Services { if !service.forMe(hosts) { continue } state, info := service.State() switch { case target == "": ls.ListServices = append(ls.ListServices, proto.ListService{ Service: service.Service, Hash: service.Hash(), State: state.String(), StateInfo: info, StateChange: service.Change().Format(time.RFC1123), }) case target != "": if service.Service == target { ls.ListServices = append(ls.ListServices, proto.ListService{ Service: service.Service, Hash: service.Hash(), State: state.String(), StateInfo: info, StateChange: service.Change().Format(time.RFC1123), }) break } } } if len(ls.ListServices) == 0 { io.WriteString(s, http.StatusText(http.StatusNotFound)) s.Exit(http.StatusNotFound) return } data, err := json.Marshal(ls) writeAndExit(s, data, err) } func FreezeService(c Config, s ssh.Session, hosts []string) { freezeStateService(c, s, StateFreeze, hosts) } func UnfreezeService(c Config, s ssh.Session, hosts []string) { freezeStateService(c, s, StateOK, hosts) } func myServices(c Config, target string, hosts []string) []*Service { var s []*Service for _, serv := range c.Services { if serv.forMe(hosts) && serv.Service == target { s = append(s, serv) } } return s } func freezeStateService(c Config, s ssh.Session, state State, hosts []string) { if len(s.Command()) < 2 { s.Exit(http.StatusNotAcceptable) return } target := s.Command()[1] for _, serv := range myServices(c, target, hosts) { serv.SetState(state, "") log.Infof("Machine %q, service %q set to %s", serv.Machine, serv.Service, state) io.WriteString(s, http.StatusText(http.StatusOK)) s.Exit(0) return } io.WriteString(s, http.StatusText(http.StatusNotFound)) s.Exit(http.StatusNotFound) } func RollbackService(c Config, s ssh.Session, hosts []string) { if len(s.Command()) < 3 { return } target := s.Command()[1] hash := s.Command()[2] if _, err := hex.DecodeString(hash); err != nil { io.WriteString(s, http.StatusText(http.StatusNotAcceptable)+", not a valid hexadecimal git hash: "+hash) s.Exit(http.StatusNotAcceptable) return } for _, serv := range myServices(c, target, hosts) { serv.SetState(StateRollback, hash) log.Infof("Machine %q, service %q set to %s", serv.Machine, serv.Service, StateRollback) io.WriteString(s, http.StatusText(http.StatusOK)) s.Exit(0) return } io.WriteString(s, http.StatusText(http.StatusNotFound)) s.Exit(http.StatusNotFound) } func PullService(c Config, s ssh.Session, hosts []string) { if len(s.Command()) < 2 { s.Exit(http.StatusNotAcceptable) return } target := s.Command()[1] for _, serv := range myServices(c, target, hosts) { log.Infof("Machine %q, service %q set to pull now", serv.Machine, serv.Service) serv.signalPullNow() io.WriteString(s, http.StatusText(http.StatusOK)) s.Exit(0) return } io.WriteString(s, http.StatusText(http.StatusNotFound)) s.Exit(http.StatusNotFound) } 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!238 blocks
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor