├── .containerignore ├── renovate.json ├── test └── integration │ ├── container │ ├── containers.conf │ └── Containerfile │ └── integration_test.go ├── Containerfile ├── go.mod ├── .github └── workflows │ ├── test.yaml │ ├── build.yaml │ └── release.yaml ├── pkg ├── utils │ ├── file.go │ ├── slice.go │ └── exec.go ├── git │ ├── url.go │ └── git.go ├── unit │ └── unit.go └── syncer │ ├── syncer.go │ └── sync.go ├── scripts ├── release └── post-release-bump ├── go.sum ├── LICENSE ├── README.md └── cmd └── orches └── main.go /.containerignore: -------------------------------------------------------------------------------- 1 | /Containerfile 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/integration/container/containers.conf: -------------------------------------------------------------------------------- 1 | [containers] 2 | netns="host" 3 | userns="host" 4 | ipcns="host" 5 | utsns="host" 6 | cgroupns="host" 7 | cgroups="disabled" 8 | log_driver = "k8s-file" 9 | [engine] 10 | cgroup_manager = "cgroupfs" 11 | events_logger="file" 12 | runtime="crun" 13 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:1.25 AS builder 2 | WORKDIR /src 3 | COPY go.mod go.sum ./ 4 | RUN go mod download 5 | COPY . /src 6 | RUN go build ./cmd/orches 7 | 8 | FROM registry.access.redhat.com/ubi9/ubi 9 | RUN dnf install -y git-core && dnf clean all 10 | COPY --from=builder /src/orches /usr/local/bin/orches 11 | ENTRYPOINT ["/usr/local/bin/orches"] 12 | WORKDIR /usr/local/bin 13 | -------------------------------------------------------------------------------- /test/integration/container/Containerfile: -------------------------------------------------------------------------------- 1 | FROM registry.access.redhat.com/ubi9-init 2 | 3 | RUN dnf install -y podman git-core && dnf clean all && \ 4 | git config --global user.email "orches@example.com" && \ 5 | git config --global user.name "Orches Test" 6 | 7 | ADD /containers.conf /etc/containers/containers.conf 8 | 9 | VOLUME /var/lib/containers 10 | 11 | ENV _CONTAINERS_USERNS_CONFIGURED="" \ 12 | BUILDAH_ISOLATION=chroot 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/orches-team/orches 2 | 3 | go 1.23.5 4 | 5 | require ( 6 | github.com/spf13/cobra v1.10.2 7 | github.com/stretchr/testify v1.11.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/spf13/pflag v1.0.9 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | merge_group: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | 16 | - name: Setup Go 17 | uses: actions/setup-go@v6 18 | with: 19 | go-version: '1.25.x' 20 | 21 | - name: Install dependencies 22 | run: go mod download 23 | 24 | - name: Run tests 25 | run: go test -v -count 1 ./... 26 | -------------------------------------------------------------------------------- /pkg/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func CopyFile(src, dst string) error { 10 | srcFile, err := os.Open(src) 11 | if err != nil { 12 | return fmt.Errorf("failed to open source file: %w", err) 13 | } 14 | defer srcFile.Close() 15 | 16 | dstFile, err := os.Create(dst) 17 | if err != nil { 18 | return fmt.Errorf("failed to create destination file: %w", err) 19 | } 20 | defer dstFile.Close() 21 | 22 | _, err = io.Copy(dstFile, srcFile) 23 | if err != nil { 24 | return fmt.Errorf("failed to copy file: %w", err) 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/utils/slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "errors" 4 | 5 | func MapSliceErr[T any, U any](slice []T, f func(T) (U, error)) ([]U, error) { 6 | result := make([]U, len(slice)) 7 | var errs []error 8 | for i, s := range slice { 9 | var err error 10 | result[i], err = f(s) 11 | if err != nil { 12 | errs = append(errs, err) 13 | } 14 | } 15 | return result, errors.Join(errs...) 16 | } 17 | 18 | func MapSlice[T any, U any](slice []T, f func(T) U) []U { 19 | result := make([]U, len(slice)) 20 | for i, s := range slice { 21 | result[i] = f(s) 22 | } 23 | return result 24 | } 25 | 26 | func FilterSlice[T any](slice []T, f func(T) bool) []T { 27 | result := []T{} 28 | for _, s := range slice { 29 | if f(s) { 30 | result = append(result, s) 31 | } 32 | } 33 | return result 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build Image 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | merge_group: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build-image: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | packages: write 18 | contents: read 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v6 23 | 24 | - name: Set up QEMU 25 | run: | 26 | sudo apt update 27 | sudo apt install -y qemu-user-static 28 | 29 | - name: Build 30 | run: | 31 | podman build --platform=linux/amd64,linux/arm64 --jobs=4 --manifest orches . 32 | 33 | - name: Push 34 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 35 | run: | 36 | echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u $ --password-stdin 37 | podman manifest push --all --format v2s2 orches ghcr.io/${{ github.repository }}:${{ github.sha }} 38 | -------------------------------------------------------------------------------- /pkg/utils/exec.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // execCommand is the base function that handles command execution 10 | func execCommand(env []string, argv ...string) ([]byte, error) { 11 | if len(argv) == 0 { 12 | return nil, fmt.Errorf("no command provided") 13 | } 14 | 15 | cmd := exec.Command(argv[0], argv[1:]...) 16 | if len(env) > 0 { 17 | cmd.Env = append(os.Environ(), env...) 18 | } 19 | 20 | out, err := cmd.CombinedOutput() 21 | if err != nil { 22 | return out, fmt.Errorf("failed to execute command: %w\noutput:\n%s", err, string(out)) 23 | } 24 | return out, nil 25 | } 26 | 27 | func ExecNoOutput(argv ...string) error { 28 | _, err := execCommand(nil, argv...) 29 | return err 30 | } 31 | 32 | func ExecOutput(argv ...string) ([]byte, error) { 33 | return execCommand(nil, argv...) 34 | } 35 | 36 | // ExecOutputEnv executes a command with additional environment variables and returns its output 37 | func ExecOutputEnv(env []string, argv ...string) ([]byte, error) { 38 | return execCommand(env, argv...) 39 | } 40 | 41 | // ExecNoOutputEnv executes a command with additional environment variables 42 | func ExecNoOutputEnv(env []string, argv ...string) error { 43 | _, err := execCommand(env, argv...) 44 | return err 45 | } 46 | -------------------------------------------------------------------------------- /pkg/git/url.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // Taken from https://github.com/go-git/go-git/blob/6b34aace4af044a6a570a17ecf96db725de2328a/internal/url/url.go#L37 4 | // Original license: Apache-2.0 5 | 6 | import ( 7 | "regexp" 8 | ) 9 | 10 | var ( 11 | isSchemeRegExp = regexp.MustCompile(`^[^:]+://`) 12 | 13 | // Ref: https://github.com/git/git/blob/master/Documentation/urls.txt#L37 14 | scpLikeUrlRegExp = regexp.MustCompile(`^(?:(?P[^@]+)@)?(?P[^:\s]+):(?:(?P[0-9]{1,5}):)?(?P[^\\].*)$`) 15 | ) 16 | 17 | // MatchesScheme returns true if the given string matches a URL-like 18 | // format scheme. 19 | func MatchesScheme(url string) bool { 20 | return isSchemeRegExp.MatchString(url) 21 | } 22 | 23 | // MatchesScpLike returns true if the given string matches an SCP-like 24 | // format scheme. 25 | func MatchesScpLike(url string) bool { 26 | return scpLikeUrlRegExp.MatchString(url) 27 | } 28 | 29 | // FindScpLikeComponents returns the user, host, port and path of the 30 | // given SCP-like URL. 31 | func FindScpLikeComponents(url string) (user, host, port, path string) { 32 | m := scpLikeUrlRegExp.FindStringSubmatch(url) 33 | return m[1], m[2], m[3], m[4] 34 | } 35 | 36 | // IsLocalEndpoint returns true if the given URL string specifies a 37 | // local file endpoint. For example, on a Linux machine, 38 | // `/home/user/src/go-git` would match as a local endpoint, but 39 | // `https://github.com/src-d/go-git` would not. 40 | func IsLocalEndpoint(url string) bool { 41 | return !MatchesScheme(url) && !MatchesScpLike(url) 42 | } 43 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import re 5 | import subprocess 6 | 7 | 8 | def get_version(): 9 | """Get current version from main.go.""" 10 | with open("cmd/orches/main.go", "r") as f: 11 | content = f.read() 12 | match = re.search(r'const version = "([^"]+)"', content) 13 | if not match: 14 | raise ValueError("Version not found in main.go") 15 | return match.group(1) 16 | 17 | 18 | def update_version(new_version): 19 | """Update version in main.go.""" 20 | with open("cmd/orches/main.go", "r") as f: 21 | content = f.read() 22 | 23 | new_content = re.sub( 24 | r'const version = "[^"]+"', 25 | f'const version = "{new_version}"', 26 | content, 27 | count=1, 28 | ) 29 | 30 | with open("cmd/orches/main.go", "w") as f: 31 | f.write(new_content) 32 | 33 | 34 | def git_commit(version): 35 | """Create a git commit and tag for the release version.""" 36 | subprocess.run(["git", "add", "cmd/orches/main.go"], check=True) 37 | subprocess.run(["git", "commit", "-m", f"release: version {version}"], check=True) 38 | 39 | 40 | def main(): 41 | # Ensure we're in the project root 42 | if not os.path.exists("cmd/orches/main.go"): 43 | raise ValueError("Must be run from project root") 44 | 45 | # Get current version and remove -dev suffix 46 | current = get_version() 47 | if not current.endswith("-dev"): 48 | raise ValueError("Current version is not a development version") 49 | 50 | release_version = current.removesuffix("-dev") 51 | 52 | # Create release commit and tag 53 | update_version(release_version) 54 | git_commit(release_version) 55 | print(f"Created release version: {release_version}") 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" # matches semantic version tags 7 | 8 | jobs: 9 | tag-release: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | packages: write 14 | contents: write # needed for creating releases 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | with: 19 | fetch-depth: 0 # needed for gh to work 20 | 21 | - name: Parse version 22 | id: release_info 23 | run: | 24 | VERSION=${GITHUB_REF#refs/tags/v} 25 | MAJOR_MINOR=$(echo $VERSION | cut -d. -f1,2) 26 | echo "version=$VERSION" >> $GITHUB_OUTPUT 27 | echo "major_minor=$MAJOR_MINOR" >> $GITHUB_OUTPUT 28 | 29 | - name: Copy images 30 | env: 31 | VERSION: ${{ steps.release_info.outputs.version }} 32 | MAJOR_MINOR: ${{ steps.release_info.outputs.major_minor }} 33 | GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | # Login to ghcr.io 36 | echo "$GHCR_TOKEN" | skopeo login ghcr.io -u $ --password-stdin 37 | 38 | # Source image is the one from the commit that was tagged 39 | SRC_IMAGE="docker://ghcr.io/${{ github.repository }}:$GITHUB_SHA" 40 | 41 | # Copy to version-specific tags 42 | skopeo copy --all "$SRC_IMAGE" "docker://ghcr.io/${{ github.repository }}:$VERSION" 43 | skopeo copy --all "$SRC_IMAGE" "docker://ghcr.io/${{ github.repository }}:$MAJOR_MINOR" 44 | skopeo copy --all "$SRC_IMAGE" "docker://ghcr.io/${{ github.repository }}:latest" 45 | 46 | - name: Create GitHub Release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: | 50 | gh release create "v${{ steps.release_info.outputs.version }}" \ 51 | --notes-from-tag 52 | -------------------------------------------------------------------------------- /pkg/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/orches-team/orches/pkg/utils" 10 | ) 11 | 12 | type Repo struct { 13 | Path string 14 | } 15 | 16 | func Clone(remote, path string) (*Repo, error) { 17 | if err := utils.ExecNoOutput("git", "clone", remote, path); err != nil { 18 | return nil, fmt.Errorf("failed to clone repo: %w", err) 19 | } 20 | 21 | return &Repo{Path: path}, nil 22 | } 23 | 24 | func (r *Repo) Fetch(remote string) error { 25 | return utils.ExecNoOutput("git", "-C", r.Path, "fetch", remote) 26 | } 27 | 28 | func (r *Repo) Ref(ref string) (string, error) { 29 | out, err := utils.ExecOutput("git", "-C", r.Path, "rev-parse", ref) 30 | if err != nil { 31 | return "", fmt.Errorf("failed to get ref: %w", err) 32 | } 33 | 34 | return strings.TrimSpace(string(out)), nil 35 | } 36 | 37 | func (r *Repo) Reset(ref string) error { 38 | return utils.ExecNoOutput("git", "-C", r.Path, "reset", "--hard", ref) 39 | } 40 | 41 | func (r *Repo) RemoteURL(remote string) (string, error) { 42 | out, err := utils.ExecOutput("git", "-C", r.Path, "remote", "get-url", remote) 43 | if err != nil { 44 | return "", fmt.Errorf("failed to get remote URL: %w", err) 45 | } 46 | 47 | return strings.TrimSpace(string(out)), nil 48 | } 49 | 50 | type worktree struct { 51 | Path string 52 | 53 | repo *Repo 54 | } 55 | 56 | func (r *Repo) NewWorktree(ref string) (*worktree, error) { 57 | worktreeDir, err := os.MkdirTemp("", "orches-worktree-") 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to create temporary directory: %w", err) 60 | } 61 | 62 | if err := utils.ExecNoOutput("git", "-C", r.Path, "worktree", "add", worktreeDir, ref); err != nil { 63 | return nil, fmt.Errorf("failed to create worktree: %w", err) 64 | } 65 | 66 | return &worktree{repo: r, Path: worktreeDir}, nil 67 | } 68 | 69 | func (wt *worktree) Cleanup() error { 70 | var errs []error 71 | errs = append(errs, utils.ExecNoOutput("git", "-C", wt.repo.Path, "worktree", "remove", wt.Path)) 72 | errs = append(errs, os.RemoveAll(wt.Path)) 73 | 74 | return errors.Join(errs...) 75 | } 76 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 10 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 11 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 12 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 13 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 14 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 15 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 16 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 17 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 18 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 19 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= 22 | github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 23 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 24 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 25 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 29 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /scripts/post-release-bump: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import re 6 | import subprocess 7 | 8 | 9 | def get_version(): 10 | """Get current version from main.go.""" 11 | with open("cmd/orches/main.go", "r") as f: 12 | content = f.read() 13 | match = re.search(r'const version = "([^"]+)"', content) 14 | if not match: 15 | raise ValueError("Version not found in main.go") 16 | return match.group(1) 17 | 18 | 19 | def parse_version(version): 20 | """Parse version string into (major, minor, patch) tuple.""" 21 | try: 22 | major, minor, patch = map(int, version.split(".")) 23 | return major, minor, patch 24 | except ValueError: 25 | raise ValueError(f"Invalid version format: {version}") 26 | 27 | 28 | def bump_version(major, minor, patch, is_minor): 29 | """Bump version and return next dev version.""" 30 | if is_minor: 31 | minor += 1 32 | patch = 0 33 | else: 34 | patch += 1 35 | 36 | next_version = f"{major}.{minor}.{patch}-dev" 37 | return next_version 38 | 39 | 40 | def update_version(new_version): 41 | """Update version in main.go.""" 42 | with open("cmd/orches/main.go", "r") as f: 43 | content = f.read() 44 | 45 | new_content = re.sub( 46 | r'const version = "[^"]+"', 47 | f'const version = "{new_version}"', 48 | content, 49 | count=1, 50 | ) 51 | 52 | with open("cmd/orches/main.go", "w") as f: 53 | f.write(new_content) 54 | 55 | 56 | def git_commit(version): 57 | """Create a git commit for the dev version.""" 58 | subprocess.run(["git", "add", "cmd/orches/main.go"], check=True) 59 | subprocess.run(["git", "commit", "-m", f"chore: bump version to {version}"], check=True) 60 | 61 | 62 | def main(): 63 | parser = argparse.ArgumentParser(description="Bump version number after release") 64 | parser.add_argument( 65 | "--minor", 66 | action="store_true", 67 | help="Bump minor version instead of patch version", 68 | ) 69 | args = parser.parse_args() 70 | 71 | # Ensure we're in the project root 72 | if not os.path.exists("cmd/orches/main.go"): 73 | raise ValueError("Must be run from project root") 74 | 75 | # Get current version 76 | current = get_version() 77 | if current.endswith("-dev"): 78 | raise ValueError("Current version is already a development version") 79 | 80 | # Parse and bump version 81 | major, minor, patch = parse_version(current) 82 | next_dev_version = bump_version(major, minor, patch, args.minor) 83 | 84 | # Create development commit 85 | update_version(next_dev_version) 86 | git_commit(next_dev_version) 87 | print(f"Bumped to development version: {next_dev_version}") 88 | 89 | 90 | if __name__ == "__main__": 91 | main() 92 | -------------------------------------------------------------------------------- /pkg/unit/unit.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | ) 8 | 9 | var homeDir string 10 | 11 | func init() { 12 | dir, err := os.UserHomeDir() 13 | if err != nil { 14 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 15 | } 16 | 17 | homeDir = dir 18 | } 19 | 20 | func ContainerDir(user bool) string { 21 | if _, err := os.Stat("/run/.containerenv"); err == nil { 22 | return "/etc/containers/systemd" 23 | } 24 | if user { 25 | return path.Join(homeDir, ".config", "containers", "systemd") 26 | } 27 | return "/etc/containers/systemd" 28 | } 29 | 30 | func ServiceDir(user bool) string { 31 | if _, err := os.Stat("/run/.containerenv"); err == nil { 32 | return "/etc/systemd/system" 33 | } 34 | if user { 35 | return path.Join(homeDir, ".config", "systemd", "user") 36 | } 37 | return "/etc/systemd/system" 38 | } 39 | 40 | type UnitType int 41 | 42 | const ( 43 | UnitTypeContainer UnitType = iota 44 | UnitTypeNetwork 45 | UnitTypeService 46 | ) 47 | 48 | type unit struct { 49 | name string 50 | content string 51 | } 52 | 53 | type Unit interface { 54 | Name() string 55 | SystemctlName() string 56 | Path(user bool) string 57 | EqualContent(Unit) bool 58 | CanBeEnabled() bool 59 | } 60 | 61 | type ErrUnknownUnitType struct { 62 | name string 63 | } 64 | 65 | func (e *ErrUnknownUnitType) Error() string { 66 | return fmt.Sprintf("unknown unit type: %v", e.name) 67 | } 68 | 69 | func New(baseDir, name string) (Unit, error) { 70 | data, err := os.ReadFile(path.Join(baseDir, name)) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | u := &unit{ 76 | name: name, 77 | content: string(data), 78 | } 79 | if u.innerTyp(name) == nil { 80 | return nil, &ErrUnknownUnitType{name: name} 81 | } 82 | return u, nil 83 | } 84 | 85 | func (u *unit) Name() string { 86 | return u.name 87 | } 88 | 89 | func (u *unit) innerTyp(name string) *UnitType { 90 | var typ UnitType 91 | 92 | switch true { 93 | case path.Ext(name) == ".container": 94 | typ = UnitTypeContainer 95 | case path.Ext(name) == ".network": 96 | typ = UnitTypeNetwork 97 | case path.Ext(name) == ".service": 98 | typ = UnitTypeService 99 | default: 100 | return nil 101 | } 102 | 103 | return &typ 104 | } 105 | 106 | func (u *unit) Typ() UnitType { 107 | return *u.innerTyp(u.name) 108 | } 109 | 110 | func (u *unit) SystemctlName() string { 111 | switch u.Typ() { 112 | case UnitTypeContainer: 113 | return u.name[:len(u.name)-len(".container")] + ".service" 114 | case UnitTypeNetwork: 115 | return u.name[:len(u.name)-len(".network")] + "-network.service" 116 | case UnitTypeService: 117 | return u.name 118 | default: 119 | panic("unknown unit type: " + u.name) 120 | } 121 | } 122 | 123 | func (u *unit) Path(user bool) string { 124 | switch u.Typ() { 125 | case UnitTypeContainer: 126 | fallthrough 127 | case UnitTypeNetwork: 128 | return path.Join(ContainerDir(user), u.name) 129 | case UnitTypeService: 130 | return path.Join(ServiceDir(user), u.name) 131 | default: 132 | panic("unknown unit type: " + u.name) 133 | } 134 | } 135 | 136 | func (u *unit) EqualContent(other Unit) bool { 137 | return u.content == other.(*unit).content 138 | } 139 | 140 | func (u *unit) CanBeEnabled() bool { 141 | return u.Typ() == UnitTypeService 142 | } 143 | -------------------------------------------------------------------------------- /pkg/syncer/syncer.go: -------------------------------------------------------------------------------- 1 | package syncer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path" 9 | 10 | "github.com/orches-team/orches/pkg/unit" 11 | "github.com/orches-team/orches/pkg/utils" 12 | ) 13 | 14 | type Syncer struct { 15 | Dry bool 16 | User bool 17 | } 18 | 19 | func (s *Syncer) createDir(dir string) error { 20 | if _, err := os.Stat(dir); err == nil { 21 | s.dryPrint("Directory exists", dir) 22 | return nil 23 | } 24 | 25 | s.dryPrint("Create", dir) 26 | 27 | return os.MkdirAll(dir, 0755) 28 | } 29 | 30 | func (s *Syncer) CreateDirs() error { 31 | var errs []error 32 | for _, dir := range []string{unit.ContainerDir(s.User), unit.ServiceDir(s.User)} { 33 | errs = append(errs, s.createDir(dir)) 34 | } 35 | return errors.Join(errs...) 36 | } 37 | 38 | func (s *Syncer) Remove(units []unit.Unit) error { 39 | errs := []error{} 40 | 41 | for _, u := range units { 42 | s.dryPrint("remove", u.Path(s.User)) 43 | if !s.Dry { 44 | errs = append(errs, os.Remove(u.Path(s.User))) 45 | } 46 | 47 | } 48 | 49 | return errors.Join(errs...) 50 | } 51 | 52 | func (s *Syncer) StopUnits(units []unit.Unit) error { 53 | return s.transitionUnits("stop", units) 54 | } 55 | 56 | func (s *Syncer) StartUnits(units []unit.Unit) error { 57 | return s.transitionUnits("start", units) 58 | } 59 | 60 | func (s *Syncer) RestartUnits(units []unit.Unit) error { 61 | return s.transitionUnits("try-restart", units) 62 | } 63 | 64 | func (s *Syncer) EnableUnits(units []unit.Unit) error { 65 | filtered := utils.FilterSlice(units, func(u unit.Unit) bool { return u.CanBeEnabled() }) 66 | if len(filtered) == 0 { 67 | return nil 68 | } 69 | return s.transitionUnits("enable", filtered) 70 | } 71 | 72 | func (s *Syncer) DisableUnits(units []unit.Unit) error { 73 | filtered := utils.FilterSlice(units, func(u unit.Unit) bool { return u.CanBeEnabled() }) 74 | if len(filtered) == 0 { 75 | return nil 76 | } 77 | return s.transitionUnits("disable", filtered) 78 | } 79 | 80 | func (s *Syncer) Add(srcDir string, units []unit.Unit) error { 81 | errs := []error{} 82 | 83 | for _, u := range units { 84 | s.dryPrint("copy", path.Join(srcDir, u.Name()), u.Path(s.User)) 85 | if !s.Dry { 86 | errs = append(errs, utils.CopyFile(path.Join(srcDir, u.Name()), u.Path(s.User))) 87 | } 88 | } 89 | 90 | return errors.Join(errs...) 91 | } 92 | 93 | func (s *Syncer) ReloadDaemon() error { 94 | return s.runSystemctl("daemon-reload") 95 | } 96 | 97 | func (s *Syncer) systemctlCmd(verb string, args ...string) []string { 98 | cmd := []string{"systemctl"} 99 | 100 | if s.User { 101 | cmd = append(cmd, "--user") 102 | } 103 | 104 | cmd = append(cmd, verb) 105 | cmd = append(cmd, args...) 106 | return cmd 107 | } 108 | 109 | func (s *Syncer) runSystemctl(verb string, args ...string) error { 110 | cmd := s.systemctlCmd(verb, args...) 111 | s.dryPrint("Run", cmd) 112 | 113 | out, err := utils.ExecOutput(cmd...) 114 | 115 | if len(out) > 0 { 116 | slog.Debug("systemctl output", "output", string(out)) 117 | } 118 | 119 | return err 120 | } 121 | 122 | func (s *Syncer) transitionUnits(verb string, units []unit.Unit) error { 123 | if len(units) == 0 { 124 | return nil 125 | } 126 | 127 | names := utils.MapSlice(units, func(u unit.Unit) string { return u.SystemctlName() }) 128 | return s.runSystemctl(verb, names...) 129 | } 130 | 131 | func (s *Syncer) dryPrint(action string, args ...any) { 132 | if s.Dry { 133 | fmt.Fprintf(os.Stderr, "%s: %v\n", action, args) 134 | } 135 | slog.Debug(fmt.Sprintf("syncer: %s", action), "args", args) 136 | } 137 | -------------------------------------------------------------------------------- /pkg/syncer/sync.go: -------------------------------------------------------------------------------- 1 | package syncer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "slices" 9 | 10 | // "github.com/orches-team/orches/pkg/git" // No longer needed here 11 | "github.com/orches-team/orches/pkg/unit" 12 | "github.com/orches-team/orches/pkg/utils" 13 | ) 14 | 15 | // PostSyncAction defines a function to be called after units are on disk and daemon reloaded, 16 | // but before services are (re)started. It's responsible for finalizing any underlying 17 | // state (like a git repository reset or directory removal) and should handle dryRun appropriately. 18 | type PostSyncAction func(dryRun bool) error 19 | 20 | type SyncResult struct { 21 | RestartNeeded bool 22 | } 23 | 24 | func SyncDirs( 25 | oldWorktreePath string, 26 | newWorktreePath string, 27 | dryRun bool, 28 | postSyncAction PostSyncAction, 29 | ) (*SyncResult, error) { 30 | oldUnits, err := listUnits(oldWorktreePath) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to list old files: %w", err) 33 | } 34 | 35 | newUnits, err := listUnits(newWorktreePath) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to list new files: %w", err) 38 | } 39 | 40 | added, removed, modified := diffUnits(oldUnits, newUnits) 41 | 42 | res, err := processChanges(newWorktreePath, added, removed, modified, dryRun, postSyncAction) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to process changes: %w", err) 45 | } 46 | 47 | return res, nil 48 | } 49 | 50 | func listUnits(dir string) (map[string]unit.Unit, error) { 51 | files := make(map[string]unit.Unit) 52 | entries, err := os.ReadDir(dir) 53 | if err != nil { 54 | return nil, err 55 | } 56 | for _, entry := range entries { 57 | if entry.IsDir() { 58 | continue 59 | } 60 | 61 | u, err := unit.New(dir, entry.Name()) 62 | var e *unit.ErrUnknownUnitType 63 | if errors.As(err, &e) { 64 | slog.Info("Skipping unknown unit type", "unit", entry.Name()) 65 | continue 66 | } else if err != nil { 67 | return nil, err 68 | } 69 | 70 | files[entry.Name()] = u 71 | } 72 | return files, err 73 | } 74 | 75 | func diffUnits(old, new map[string]unit.Unit) (added, removed, changed []unit.Unit) { 76 | for file, u := range old { 77 | if _, exists := new[file]; !exists { 78 | removed = append(removed, u) 79 | } 80 | } 81 | for file, u := range new { 82 | if _, exists := old[file]; !exists { 83 | added = append(added, u) 84 | } 85 | } 86 | for file, u := range new { 87 | if oldU, exists := old[file]; exists && !u.EqualContent(oldU) { 88 | changed = append(changed, u) 89 | } 90 | } 91 | 92 | return 93 | } 94 | 95 | func processChanges( 96 | newDir string, // This is newWorktreePath 97 | added, removed, modified []unit.Unit, 98 | dryRun bool, 99 | postSyncAction PostSyncAction, 100 | ) (*SyncResult, error) { 101 | if len(added) == 0 && len(removed) == 0 && len(modified) == 0 { 102 | fmt.Fprintf(os.Stderr, "No changes to process.") 103 | // Execute postSyncAction even if no unit changes, as the underlying repo might have changed. 104 | if postSyncAction != nil { 105 | if err := postSyncAction(dryRun); err != nil { 106 | return nil, fmt.Errorf("post sync action failed even with no unit changes: %w", err) 107 | } 108 | } 109 | return &SyncResult{}, nil 110 | } 111 | 112 | if len(added) > 0 { 113 | fmt.Fprintf(os.Stderr, "Added: %v\n", utils.MapSlice(added, func(u unit.Unit) string { return u.Name() })) 114 | } 115 | if len(removed) > 0 { 116 | fmt.Fprintf(os.Stderr, "Removed: %v\n", utils.MapSlice(removed, func(u unit.Unit) string { return u.Name() })) 117 | } 118 | if len(modified) > 0 { 119 | fmt.Fprintf(os.Stderr, "Modified: %v\n", utils.MapSlice(modified, func(u unit.Unit) string { return u.Name() })) 120 | } 121 | 122 | s := Syncer{ 123 | Dry: dryRun, 124 | User: os.Getuid() != 0, 125 | } 126 | 127 | isOrches := func(u unit.Unit) bool { return u.Name() == "orches.container" } 128 | 129 | restartNeeded := false 130 | 131 | toRestart := modified 132 | toStop := removed 133 | if slices.ContainsFunc(modified, isOrches) { 134 | toRestart = slices.DeleteFunc(append([]unit.Unit{}, modified...), isOrches) 135 | fmt.Println("orches.container was changed") 136 | restartNeeded = true 137 | } else if slices.ContainsFunc(removed, isOrches) { 138 | toStop = slices.DeleteFunc(append([]unit.Unit{}, removed...), isOrches) 139 | fmt.Println("orches.container was removed") 140 | restartNeeded = true 141 | } 142 | 143 | if err := s.CreateDirs(); err != nil { 144 | return nil, fmt.Errorf("failed to create directories: %w", err) 145 | } 146 | 147 | if err := s.DisableUnits(removed); err != nil { 148 | return nil, fmt.Errorf("failed to disable unit: %w", err) 149 | } 150 | 151 | if err := s.StopUnits(toStop); err != nil { 152 | return nil, fmt.Errorf("failed to stop unit: %w", err) 153 | } 154 | 155 | if err := s.Remove(removed); err != nil { 156 | return nil, fmt.Errorf("failed to remove unit: %w", err) 157 | } 158 | 159 | if err := s.Add(newDir, append(added, modified...)); err != nil { 160 | return nil, fmt.Errorf("failed to add unit: %w", err) 161 | } 162 | 163 | if err := s.ReloadDaemon(); err != nil { 164 | return nil, fmt.Errorf("failed to reload daemon: %w", err) 165 | } 166 | 167 | // Perform the post-sync action (e.g., git reset, directory removal) 168 | if postSyncAction != nil { 169 | slog.Info("Executing post-sync action") 170 | if err := postSyncAction(s.Dry); err != nil { // Pass syncer's dryRun state 171 | return nil, fmt.Errorf("post-sync action failed: %w", err) 172 | } 173 | slog.Info("Post-sync action completed successfully") 174 | } else { 175 | slog.Info("No post-sync action provided") 176 | } 177 | 178 | if err := s.RestartUnits(toRestart); err != nil { 179 | return nil, fmt.Errorf("failed to restart unit: %w", err) 180 | } 181 | 182 | if err := s.StartUnits(append(added, toRestart...)); err != nil { 183 | return nil, fmt.Errorf("failed to start unit: %w", err) 184 | } 185 | 186 | if err := s.EnableUnits(added); err != nil { 187 | return nil, fmt.Errorf("failed to enable unit: %w", err) 188 | } 189 | 190 | return &SyncResult{RestartNeeded: restartNeeded}, nil 191 | } 192 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /test/integration/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/orches-team/orches/pkg/utils" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | var cid string 19 | 20 | func TestMain(m *testing.M) { 21 | code := 1 22 | defer func() { os.Exit(code) }() 23 | 24 | tmpDir, err := os.MkdirTemp("", "orches-test-") 25 | if err != nil { 26 | fmt.Printf("failed to create orches temp dir: %v", err) 27 | panic(err) 28 | } 29 | defer os.RemoveAll(tmpDir) 30 | 31 | // Build orches binary 32 | arch := runtime.GOARCH // host arch == target arch we want inside the container 33 | env := []string{"GOOS=linux", fmt.Sprintf("GOARCH=%s", arch), "CGO_ENABLED=0"} 34 | err = utils.ExecNoOutputEnv(env, "go", "build", "-o", filepath.Join(tmpDir, "orches"), "../../cmd/orches") 35 | if err != nil { 36 | fmt.Printf("failed to build orches: %v", err) 37 | panic(err) 38 | } 39 | 40 | err = utils.ExecNoOutput("podman", "build", "-t", "orches-testbase", "./container") 41 | if err != nil { 42 | fmt.Printf("failed to build orches-testbase: %v", err) 43 | panic(err) 44 | } 45 | 46 | c, err := utils.ExecOutput("podman", "run", "--quiet", "--rm", "-d", "-v", tmpDir+":/app:Z", "--privileged", "orches-testbase") 47 | if err != nil { 48 | fmt.Printf("failed to run orches-testbase: %v", err) 49 | panic(err) 50 | } 51 | cid = strings.TrimSpace(string(c)) 52 | 53 | defer func() { 54 | err := utils.ExecNoOutput("podman", "stop", cid) 55 | if err != nil { 56 | utils.ExecNoOutput("podman", "kill", cid) 57 | } 58 | }() 59 | 60 | code = m.Run() 61 | } 62 | 63 | func cmd(args ...string) []string { 64 | return append([]string{"podman", "exec", cid}, args...) 65 | } 66 | 67 | func run(t *testing.T, args ...string) []byte { 68 | out, err := runUnchecked(args...) 69 | require.NoError(t, err) 70 | return out 71 | } 72 | 73 | func runUnchecked(args ...string) ([]byte, error) { 74 | return utils.ExecOutput(cmd(args...)...) 75 | } 76 | 77 | func runOrches(t *testing.T, args ...string) []byte { 78 | args = append([]string{"/app/orches", "-vv"}, args...) 79 | return run(t, args...) 80 | } 81 | 82 | func addFile(t *testing.T, path, content string) { 83 | cmd := exec.Command("podman", "exec", "-i", cid, "tee", path) 84 | cmd.Stdin = strings.NewReader(content) 85 | 86 | out, err := cmd.CombinedOutput() 87 | if err != nil { 88 | t.Log(string(out)) 89 | t.FailNow() 90 | } 91 | } 92 | 93 | func TestAux(t *testing.T) { 94 | output := runOrches(t, "help") 95 | 96 | assert.Contains(t, string(output), "orches") 97 | assert.Contains(t, string(output), "Usage:") 98 | assert.Contains(t, string(output), "sync") 99 | assert.Contains(t, string(output), "switch") 100 | 101 | output = runOrches(t, "version") 102 | assert.Contains(t, string(output), "gitref") 103 | assert.Contains(t, string(output), "buildtime") 104 | } 105 | 106 | func TestSmokePodman(t *testing.T) { 107 | output := run(t, "podman", "run", "--rm", "--quiet", "alpine", "echo", "hello") 108 | 109 | assert.Contains(t, string(output), "hello") 110 | } 111 | 112 | const testdir = "/orchestest" 113 | const testdir2 = "/orchestest2" 114 | 115 | func cleanup(t *testing.T) { 116 | // ADD ALL UNITS USED IN TESTS HERE 117 | for _, unit := range []string{"caddy", "caddy2", "orches"} { 118 | runUnchecked("systemctl", "stop", unit) 119 | } 120 | 121 | run(t, "rm", "-rf", testdir) 122 | run(t, "rm", "-rf", testdir2) 123 | run(t, "rm", "-rf", "/etc/containers/systemd") 124 | run(t, "rm", "-rf", "/var/lib/orches") 125 | } 126 | 127 | func commit(t *testing.T, dir string) { 128 | run(t, "git", "-C", dir, "add", ".") 129 | run(t, "git", "-C", dir, "commit", "-m", "commit") 130 | } 131 | 132 | func addAndCommit(t *testing.T, path, content string) { 133 | addFile(t, path, content) 134 | commit(t, filepath.Dir(path)) 135 | } 136 | 137 | func removeAndCommit(t *testing.T, path string) { 138 | run(t, "rm", path) 139 | commit(t, filepath.Dir(path)) 140 | } 141 | 142 | func TestOrches(t *testing.T) { 143 | defer cleanup(t) 144 | 145 | run(t, "mkdir", "-p", testdir) 146 | run(t, "git", "-C", testdir, "init") 147 | 148 | // Init with caddy on 8080 149 | addAndCommit(t, filepath.Join(testdir, "caddy.container"), `[Container] 150 | Image=docker.io/library/caddy:alpine 151 | Exec=/usr/bin/caddy file-server --listen :8080 --root /usr/share/caddy 152 | `) 153 | 154 | runOrches(t, "init", testdir) 155 | 156 | run(t, "ls", "/etc/containers/systemd/caddy.container") 157 | 158 | out := run(t, "systemctl", "status", "caddy") 159 | assert.Contains(t, string(out), "Active: active (running)") 160 | 161 | out = run(t, "curl", "-s", "http://localhost:8080") 162 | assert.Contains(t, string(out), "Caddy") 163 | 164 | // Move caddy to 9090 165 | addAndCommit(t, filepath.Join(testdir, "caddy.container"), `[Container] 166 | Image=docker.io/library/caddy:alpine 167 | Exec=/usr/bin/caddy file-server --listen :9090 --root /usr/share/caddy 168 | `) 169 | 170 | runOrches(t, "sync") 171 | 172 | out = run(t, "systemctl", "status", "caddy") 173 | assert.Contains(t, string(out), "Active: active (running)") 174 | 175 | out = run(t, "curl", "-s", "http://localhost:9090") 176 | assert.Contains(t, string(out), "Caddy") 177 | 178 | // Drop caddy, and spawn it again as a different container on 8888 179 | removeAndCommit(t, filepath.Join(testdir, "caddy.container")) 180 | addAndCommit(t, filepath.Join(testdir, "caddy2.container"), `[Container] 181 | Image=docker.io/library/caddy:alpine 182 | Exec=/usr/bin/caddy file-server --listen :8888 --root /usr/share/caddy 183 | `) 184 | 185 | runOrches(t, "sync") 186 | 187 | out, err := runUnchecked("systemctl", "status", "caddy") 188 | assert.Error(t, err) 189 | assert.Contains(t, string(out), "Unit caddy.service could not be found.") 190 | 191 | _, err = runUnchecked("curl", "-s", "http://localhost:9090") 192 | assert.Error(t, err) 193 | 194 | out = run(t, "systemctl", "status", "caddy2") 195 | assert.Contains(t, string(out), "Active: active (running)") 196 | 197 | out = run(t, "curl", "-s", "http://localhost:8888") 198 | assert.Contains(t, string(out), "Caddy") 199 | 200 | // Prune 201 | runOrches(t, "prune") 202 | 203 | out, err = runUnchecked("systemctl", "status", "caddy") 204 | assert.Error(t, err) 205 | assert.Contains(t, string(out), "Unit caddy.service could not be found.") 206 | 207 | out, err = runUnchecked("systemctl", "status", "caddy2") 208 | assert.Error(t, err) 209 | assert.Contains(t, string(out), "Unit caddy2.service could not be found.") 210 | 211 | _, err = runUnchecked("ls", "/etc/containers/systemd/caddy.container") 212 | assert.Error(t, err) 213 | 214 | _, err = runUnchecked("ls", "/var/lib/orches/repo") 215 | assert.Error(t, err) 216 | } 217 | 218 | func TestOrchesSelfUpdate(t *testing.T) { 219 | defer cleanup(t) 220 | 221 | run(t, "mkdir", "-p", testdir) 222 | run(t, "git", "-C", testdir, "init") 223 | 224 | // Let's mock orches with caddy 225 | addAndCommit(t, filepath.Join(testdir, "orches.container"), `[Container] 226 | Image=docker.io/library/caddy:alpine 227 | Exec=/usr/bin/caddy file-server --listen :8080 --root /usr/share/caddy 228 | `) 229 | 230 | runOrches(t, "init", testdir) 231 | 232 | out := run(t, "systemctl", "status", "orches") 233 | assert.Contains(t, string(out), "Active: active (running)") 234 | 235 | // Start the run process 236 | syncCmd := cmd("/app/orches", "-vv", "run", "--interval", "1") 237 | cmd := exec.Command(syncCmd[0], syncCmd[1:]...) 238 | require.NoError(t, cmd.Start()) 239 | 240 | // Fake an update 241 | addAndCommit(t, filepath.Join(testdir, "orches.container"), `[Container] 242 | Image=docker.io/library/caddy:alpine 243 | Exec=/usr/bin/caddy file-server --listen :9090 --root /usr/share/caddy 244 | `) 245 | 246 | // Wait for the sync for a bit 247 | time.Sleep(2 * time.Second) 248 | 249 | // Now let's verify the faked update 250 | // The process itself should have died 251 | require.NoError(t, cmd.Wait()) 252 | 253 | // The service should still be running (because orches doesn't stop itself) 254 | out = run(t, "systemctl", "status", "orches") 255 | assert.Contains(t, string(out), "Active: active (running)") 256 | 257 | // But the service file should have been updated 258 | out = run(t, "cat", "/etc/containers/systemd/orches.container") 259 | assert.Contains(t, string(out), ":9090") 260 | } 261 | 262 | func TestOrchesSwitchRepo(t *testing.T) { 263 | defer cleanup(t) 264 | 265 | // Create first repo 266 | run(t, "mkdir", "-p", testdir) 267 | run(t, "git", "-C", testdir, "init") 268 | 269 | // Add initial caddy container on 8080 270 | addAndCommit(t, filepath.Join(testdir, "caddy.container"), `[Container] 271 | Image=docker.io/library/caddy:alpine 272 | Exec=/usr/bin/caddy file-server --listen :8080 --root /usr/share/caddy 273 | `) 274 | 275 | runOrches(t, "init", testdir) 276 | 277 | // Verify initial state 278 | out := run(t, "systemctl", "status", "caddy") 279 | assert.Contains(t, string(out), "Active: active (running)") 280 | out = run(t, "curl", "-s", "http://localhost:8080") 281 | assert.Contains(t, string(out), "Caddy") 282 | 283 | // Start the run process 284 | syncCmd := cmd("/app/orches", "-vv", "run", "--interval", "10") 285 | cmd := exec.Command(syncCmd[0], syncCmd[1:]...) 286 | require.NoError(t, cmd.Start()) 287 | 288 | // Give the daemon time to start 289 | time.Sleep(2 * time.Second) 290 | 291 | // Create second repo 292 | run(t, "mkdir", "-p", testdir2) 293 | run(t, "git", "-C", testdir2, "init") 294 | 295 | // Add different caddy config in new repo 296 | addAndCommit(t, filepath.Join(testdir2, "caddy.container"), `[Container] 297 | Image=docker.io/library/caddy:alpine 298 | Exec=/usr/bin/caddy file-server --listen :9090 --root /usr/share/caddy 299 | `) 300 | 301 | // Switch to new repo 302 | runOrches(t, "switch", testdir2) 303 | 304 | // Give the daemon time to exit 305 | time.Sleep(1 * time.Second) 306 | 307 | // Verify the daemon process exited 308 | err := cmd.Wait() 309 | assert.NoError(t, err, "orches process should exit cleanly after switch") 310 | 311 | // Verify the switch worked 312 | out = run(t, "systemctl", "status", "caddy") 313 | assert.Contains(t, string(out), "Active: active (running)") 314 | 315 | // Old port should not work 316 | _, err = runUnchecked("curl", "-s", "http://localhost:8080") 317 | assert.Error(t, err) 318 | 319 | // New port should work 320 | out = run(t, "curl", "-s", "http://localhost:9090") 321 | assert.Contains(t, string(out), "Caddy") 322 | 323 | // Verify repo status shows new path 324 | out = runOrches(t, "status") 325 | assert.Contains(t, string(out), testdir2) 326 | } 327 | 328 | func TestOrchesRun(t *testing.T) { 329 | defer cleanup(t) 330 | 331 | // Create initial repo 332 | run(t, "mkdir", "-p", testdir) 333 | run(t, "git", "-C", testdir, "init") 334 | 335 | // Add initial caddy container on 8080 336 | addAndCommit(t, filepath.Join(testdir, "caddy.container"), `[Container] 337 | Image=docker.io/library/caddy:alpine 338 | Exec=/usr/bin/caddy file-server --listen :8080 --root /usr/share/caddy 339 | `) 340 | 341 | runOrches(t, "init", testdir) 342 | 343 | // Verify initial state 344 | out := run(t, "systemctl", "status", "caddy") 345 | assert.Contains(t, string(out), "Active: active (running)") 346 | out = run(t, "curl", "-s", "http://localhost:8080") 347 | assert.Contains(t, string(out), "Caddy") 348 | 349 | // Start the run process 350 | syncCmd := cmd("/app/orches", "-vv", "run", "--interval", "10") 351 | cmd := exec.Command(syncCmd[0], syncCmd[1:]...) 352 | require.NoError(t, cmd.Start()) 353 | 354 | // Give the daemon time to start 355 | time.Sleep(2 * time.Second) 356 | 357 | // Update caddy to use port 9090 358 | addAndCommit(t, filepath.Join(testdir, "caddy.container"), `[Container] 359 | Image=docker.io/library/caddy:alpine 360 | Exec=/usr/bin/caddy file-server --listen :9090 --root /usr/share/caddy 361 | `) 362 | 363 | // Send sync command to daemon 364 | runOrches(t, "sync") 365 | 366 | // Give it time to process 367 | time.Sleep(2 * time.Second) 368 | 369 | // Verify the update was applied 370 | out = run(t, "systemctl", "status", "caddy") 371 | assert.Contains(t, string(out), "Active: active (running)") 372 | 373 | // Old port should not work 374 | _, err := runUnchecked("curl", "-s", "http://localhost:8080") 375 | assert.Error(t, err) 376 | 377 | // New port should work 378 | out = run(t, "curl", "-s", "http://localhost:9090") 379 | assert.Contains(t, string(out), "Caddy") 380 | 381 | // Send prune command to daemon 382 | runOrches(t, "prune") 383 | 384 | // Give it time to process 385 | time.Sleep(2 * time.Second) 386 | 387 | // Verify prune worked 388 | out, err = runUnchecked("systemctl", "status", "caddy") 389 | assert.Error(t, err) 390 | assert.Contains(t, string(out), "Unit caddy.service could not be found.") 391 | 392 | _, err = runUnchecked("ls", "/etc/containers/systemd/caddy.container") 393 | assert.Error(t, err) 394 | 395 | _, err = runUnchecked("ls", "/var/lib/orches/repo") 396 | assert.Error(t, err) 397 | 398 | // Give orches time to exit 399 | time.Sleep(1 * time.Second) 400 | 401 | // Verify orches process exited after prune 402 | err = cmd.Wait() 403 | assert.NoError(t, err, "orches process should exit cleanly after prune") 404 | } 405 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![orches logo](https://raw.githubusercontent.com/orches-team/common/main/orches-logo-text.png) 2 | 3 | # orches: Simple git-ops for Podman and systemd 4 | 5 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | Content: 8 | 9 | - [Overview](#overview) 10 | - [Project Status](#project-status) 11 | - [Quick Start](#quick-start) 12 | - [CLI documentation](#cli-documentation) 13 | - [Supported units](#supported-units) 14 | - [FAQ](#faq) 15 | 16 | ## Overview 17 | 18 | orches is a simple git-ops tool for orchestrating [Podman](https://podman.io/) containers and systemd units on a single machine. It is loosely inspired by [Argo CD](https://argo-cd.readthedocs.io/en/stable/) and [Flux CD](https://fluxcd.io/), but without the need for Kubernetes. 19 | 20 | Containers in orches are defined by [Podman Quadlets](https://www.redhat.com/en/blog/quadlet-podman). A super simple example of such a file can look like this: 21 | 22 | ```ini 23 | [Container] 24 | Image=docker.io/library/caddy:2.9.1-alpine 25 | PublishPort=8080:80 26 | 27 | [Install] 28 | WantedBy=multi-user.target 29 | ``` 30 | 31 | All you need to start using orches is to create a repository, include this file, and run `orches init REPO_PATH` to sync your local system with the repository content. 32 | 33 | orches is not limited to Podman containers, but it is also able to manage generic systemd units. This makes it a great pick for managing both containerized, and non-containerized workloads. orches is able to run both system and [user](https://wiki.archlinux.org/title/Systemd/User) systemd units. 34 | 35 | ## Project Status 36 | 37 | ⚠️ **Project Maturity Warning**: orches is a young project that is currently being used on small production servers. While it is stable enough for basic production use, you may encounter rough edges. 38 | 39 | ## Quick Start 40 | 41 | > **Tip:** For a ready-to-use rootless example with many popular services (like Jellyfin, Pi-hole, Homarr, and more), check out [github.com/orches-team/example](https://github.com/orches-team/example). This repository provides a comprehensive orches configuration you can fork or use as inspiration for your own setup. 42 | 43 | orches can run both rootless and rootful. While running rootless offers stronger security, some applications cannot be run in such a setup. We provide sample configuration for both modes. If you are not sure which one to pick, start with rootless, it's simple to switch to rootful later if you need to. 44 | 45 | In order to run orches, you need: 46 | 47 | - podman >= 4.4 48 | - systemd 49 | 50 | orches has been tested on Fedora 41, Ubuntu 24.04, and CentOS Stream 9 and its derivates 51 | 52 | ### Initializing orches with a rootless config 53 | 54 | To start using rootless orches, simply run the following commands: 55 | 56 | ```bash 57 | loginctl enable-linger $(whoami) 58 | 59 | mkdir -p ~/.config/orches ~/.config/containers/systemd 60 | 61 | podman run --rm -it --userns=keep-id --pid=host --pull=newer \ 62 | --mount \ 63 | type=bind,source=/run/user/$(id -u)/systemd,destination=/run/user/$(id -u)/systemd \ 64 | -v ~/.config/orches:/var/lib/orches \ 65 | -v ~/.config/containers/systemd:/etc/containers/systemd \ 66 | --env XDG_RUNTIME_DIR=/run/user/$(id -u) \ 67 | ghcr.io/orches-team/orches init \ 68 | https://github.com/orches-team/orches-config-rootless.git 69 | ``` 70 | 71 | These commands: 72 | 1. Enable [lingering](https://wiki.archlinux.org/title/Systemd/User#Automatic_start-up_of_systemd_user_instances) for orches and managed apps to start on boot. 73 | 2. Create directories required by orches. 74 | 3. Initialize orches via its `init` subcommand, using the official rootless sample repository (containing orches and a caddy webserver). The specified `podman run` flags grant orches permission to control systemd user units. 75 | 76 | Once you run the command, you should be able to verify that orches, and the webserver is running: 77 | 78 | ```bash 79 | systemctl --user status orches 80 | systemctl --user status caddy 81 | podman exec systemd-orches orches status 82 | curl localhost:8080 83 | ``` 84 | 85 | ### Initializing orches with a rootful config 86 | 87 | To start using rootful orches, simply run the following commands: 88 | 89 | ```bash 90 | sudo mkdir -p /var/lib/orches /etc/containers/systemd 91 | 92 | sudo podman run --rm -it --pid=host --pull=newer \ 93 | --mount \ 94 | type=bind,source=/run/systemd,destination=/run/systemd \ 95 | -v /var/lib/orches:/var/lib/orches \ 96 | -v /etc/containers/systemd:/etc/containers/systemd \ 97 | ghcr.io/orches-team/orches init \ 98 | https://github.com/orches-team/orches-config-rootful.git 99 | ``` 100 | 101 | These commands: 102 | 1. Create directories required by orches. 103 | 2. Initialize orches via its `init` subcommand, using the official rootful sample repository (containing orches and a caddy webserver). The specified `podman run` flags grant orches permission to control systemd units. 104 | 105 | Once you run the command, you should be able to verify that orches, and the webserver is running: 106 | 107 | ```bash 108 | systemctl status orches 109 | systemctl status caddy 110 | sudo podman exec systemd-orches orches status 111 | curl localhost:8080 112 | ``` 113 | 114 | ### Customizing your deployment 115 | 116 | You now have orches and up and running. Let's add an actually useful application, a [Jellyfin media server](https://jellyfin.org/), to the deployment. Firstly, you need to fork the template repository ([rootless](https://github.com/orches-team/orches-config-rootless), [rootful](https://github.com/orches-team/orches-config-rootful)) that you started with in the previous step. 117 | 118 | Once you have your fork created, clone it locally, and add the following file as `jellyfin.service`: 119 | 120 | ```ini 121 | [Container] 122 | Image=docker.io/jellyfin/jellyfin 123 | Volume=config:/config:Z 124 | Volume=cache:/cache:Z 125 | Volume=media:/media:Z 126 | PublishPort=8096:8096 127 | 128 | [Install] 129 | WantedBy=multi-user.target default.target 130 | ``` 131 | 132 | Commit the file, and push to your fork. Now, it's time to tell orches to use your fork instead of the sample repository. Run the following command on your host running orches: 133 | 134 | ```bash 135 | podman exec systemd-orches orches switch ${YOUR_FORK_URL} 136 | ``` 137 | 138 | You should now be able to navigate to and see your new Jellyfin instance. 139 | 140 | ### Updating your deployment 141 | 142 | Now that you know how to deploy new containers, it's also time to learn how to modify, or remove existing ones. 143 | 144 | Firstly, let's modify the Jellyfin one to automatically restart itself if it fails: 145 | 146 | ```diff 147 | [Container] 148 | Image=docker.io/jellyfin/jellyfin 149 | Volume=config:/config:Z 150 | Volume=cache:/cache:Z 151 | Volume=media:/media:Z 152 | PublishPort=8096:8096 153 | 154 | + [Service] 155 | + Restart=on-failure 156 | 157 | [Install] 158 | WantedBy=multi-user.target default.target 159 | ``` 160 | 161 | Secondly, delete the sample webserver (`caddy.container`) from the repository. Now, commit all changes, and push them to your remote repository. 162 | 163 | orches checks for changes and applies them every 2 minutes. If you are impatient, you can trigger a sync manually with `podman exec systemd-orches orches sync`. After either 2 minutes, or a manual sync, you should see the jellyfin service restarted, and the caddy service removed. You can check this with: 164 | 165 | ```bash 166 | systemctl status jellyfin 167 | systemctl status caddy 168 | ``` 169 | 170 | ### Removing orches 171 | 172 | If you want to remove orches altogether, just run: 173 | 174 | ```bash 175 | podman exec systemd-orches orches prune 176 | ``` 177 | 178 | ## CLI documentation 179 | 180 | This section describes orches CLI and all its flags and subcommands. 181 | 182 | ### Global flags 183 | 184 | | Flag | Description | 185 | |-------------|---------------------------------------------------------------------------------------| 186 | | `--dry` | Instructs orches to just print what it would do, but no changes are actually applied. | 187 | | `--verbose` | Turns on verbose logging. | 188 | 189 | 190 | ### `orches init REF` 191 | 192 | Initializes orches from the given `REF`. `REF` accepts the same formats as `git clone` does. 193 | 194 | ### `orches sync` 195 | 196 | Instructs orches to check for changes in the target repository, and apply them. 197 | 198 | ### `orches run` 199 | 200 | Starts orches as a daemon. This basically runs `orches sync` every 2 minutes. Send SIGINT (ctrl+C), or SIGTERM to stop. 201 | 202 | Flags: 203 | 204 | | Flag | Default | Description | 205 | |--------------|---------|--------------------------------------------| 206 | | `--interval` | 120 | How often the sync is performed in seconds | 207 | 208 | ### `orches switch REF` 209 | 210 | Switches orches to deploy from `REF` instead of its current target. `REF` accepts the same formats as `git clone` does. 211 | 212 | ### `orches prune` 213 | 214 | Stops and removes all units managed by orches, and removes the local checkout of the repository - returning the system to a pristine state. 215 | 216 | ### `orches status` 217 | 218 | Prints information about the current target and the deployed commit. The output format is yaml. 219 | 220 | ### `orches version` 221 | 222 | Prints orches version and some details about its build. The output format is yaml. 223 | 224 | ## Supported units 225 | 226 | Orches supports the following unit types: 227 | 228 | | File extension | Description | 229 | |----------------|-------------------------------------------------------------------------------------------------------------------------| 230 | | `.container` | Podman [container unit](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#container-units-container) | 231 | | `.network` | Podman [network unit](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#network-units-network) | 232 | | `.service` | Ordinary [systemd service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html) | 233 | 234 | 235 | orches only process units in the top level directory of the repository. All directories in the repository are currently ignored. 236 | 237 | Additionally, all units with unknown extensions are ignored. You can use this to your advantage. Simply rename `web.container` to `web.container.ignored`, and orches will remove this container during the next sync. 238 | 239 | Podman units [cannot be enabled](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#enabling-unit-files), orches only runs start/stop/try-restart one them. Plain systemd service units are also enabled, or disabled. 240 | 241 | Units are restarted when a change in them is detected. The algorithm is naive, it just compares the old file, and the new one byte by byte. 242 | 243 | ## FAQ 244 | 245 | This is a list of practical Frequently Asked Questions about running orches. 246 | 247 | ### Can I use a private repository? 248 | 249 | Certainly! It's recommended to start with a public fork of one of the starter repositories. Make sure that your are using the SSH remote path when running `switch`. Once you have your deployment switched, copy an unencrypted private ssh key to the server and add a volume to your `orches.container`: 250 | 251 | ```ini 252 | Volume=PATH_TO_YOUR_SSH_KEY:%h/.ssh/id_rsa 253 | ``` 254 | 255 | Now sync your deployment, make your fork private, and sync it again to verify that orches can still pull from the repository. 256 | 257 | 258 | ### Can I also manage configuration files for my containers using orches? 259 | 260 | Yes, orches makes it easy to manage configuration files alongside your container unit files. Here's how it works: 261 | 262 | 1. Store your configuration files in your orches-managed git repository alongside your unit files 263 | 2. Reference these files in your container units using relative paths 264 | 3. Use the `X-Version` key in your unit files to trigger container restarts when configs change 265 | 266 | > The `X-Version` field is needed because orches only detects changes to the unit files themselves, not to external files referenced by them. When you update a configuration file, orches won't automatically know to restart the container since the unit file hasn't changed. By incrementing `X-Version`, you force the unit file to be different, which triggers orches to restart the container with the new configuration. 267 | 268 | **Rootless Example (`~/.config/orches/repo`):** 269 | ```ini 270 | [Container] 271 | Image=docker.io/library/caddy:alpine 272 | Volume=%h/.config/orches/repo/Caddyfile:/etc/caddy/Caddyfile:z 273 | X-Version=1 274 | 275 | [Install] 276 | WantedBy=multi-user.target default.target 277 | ``` 278 | 279 | **Rootful Example (`/var/lib/orches/repo`):** 280 | ```ini 281 | [Container] 282 | Image=docker.io/library/caddy:alpine 283 | Volume=/var/lib/orches/repo/Caddyfile:/etc/caddy/Caddyfile:z 284 | X-Version=1 285 | 286 | [Install] 287 | WantedBy=multi-user.target default.target 288 | ``` 289 | 290 | When you update a configuration file in your repository, increment the `X-Version` value in the corresponding container unit file. This ensures orches restarts the container with the new configuration during the next sync. 291 | 292 | ### Can I just use `:latest` instead of pinning my container images? 293 | 294 | You can use `:latest` or any other floating tag, but it's generally discouraged for production deployments because it can lead to unexpected updates and breakages. If you do want to use auto-updating images, Podman supports this via the `AutoUpdate=registry` option in your Quadlet file. 295 | 296 | When you enable auto updates for your containers with the line `AutoUpdate=registry` in a Quadlet file you also need to enable and start the `podman-auto-update` service for that specific user (i.e. `systemctl --user enable podman-auto-update`), otherwise it won't update the image(s). 297 | 298 | For more information, see the [Podman auto-update documentation](https://docs.podman.io/en/latest/markdown/podman-auto-update.1.html). 299 | -------------------------------------------------------------------------------- /cmd/orches/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net" 10 | "os" 11 | "os/signal" 12 | "path" 13 | "path/filepath" 14 | "runtime/debug" 15 | "strings" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/orches-team/orches/pkg/git" 20 | "github.com/orches-team/orches/pkg/syncer" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | const version = "0.1.1-dev" 25 | 26 | var baseDir string 27 | 28 | func init() { 29 | if _, err := os.Stat("/run/.containerenv"); err == nil { 30 | baseDir = "/var/lib/orches" 31 | } else if os.Getuid() != 0 { 32 | dir, err := os.UserHomeDir() 33 | if err != nil { 34 | panic(fmt.Sprintf("failed to get user home directory: %v", err)) 35 | } 36 | // TODO: RESPECT XDG 37 | baseDir = path.Join(dir, ".config", "orches") 38 | } else { 39 | baseDir = "/var/lib/orches" 40 | } 41 | } 42 | 43 | type rootFlags struct { 44 | dryRun bool 45 | } 46 | 47 | type daemonCommand struct { 48 | Name string `json:"name"` 49 | Arg string `json:"arg"` 50 | } 51 | 52 | func handleConnection(sock net.Listener, cmdChan chan<- daemonCommand, resultChan <-chan string) error { 53 | conn, err := sock.Accept() 54 | if err != nil { 55 | return err 56 | } 57 | defer conn.Close() 58 | 59 | var cmd daemonCommand 60 | if err := json.NewDecoder(conn).Decode(&cmd); err != nil { 61 | return err 62 | } 63 | 64 | slog.Debug("Received command", "name", cmd.Name, "arg", cmd.Arg) 65 | 66 | cmdChan <- cmd 67 | status := <-resultChan 68 | _, err = io.Copy(conn, strings.NewReader(status)) 69 | if err != nil { 70 | return fmt.Errorf("failed to send the status: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func waitForCommands(sock net.Listener) (<-chan daemonCommand, chan<- string) { 77 | cmdChan := make(chan daemonCommand) 78 | resultChan := make(chan string) 79 | 80 | go func() { 81 | for { 82 | err := handleConnection(sock, cmdChan, resultChan) 83 | if errors.Is(err, net.ErrClosed) { 84 | fmt.Fprintf(os.Stderr, "Socket closed, stopping the listener.\n") 85 | break 86 | } 87 | if err != nil { 88 | fmt.Fprintf(os.Stderr, "Failed to handle connection: %v\n", err) 89 | } 90 | } 91 | }() 92 | 93 | return cmdChan, resultChan 94 | } 95 | 96 | func getRootFlags(cmd *cobra.Command) rootFlags { 97 | dryRun, _ := cmd.Flags().GetBool("dry") 98 | return rootFlags{dryRun: dryRun} 99 | } 100 | 101 | func socketPath() string { 102 | return path.Join(baseDir, "socket") 103 | } 104 | 105 | func socketExists() bool { 106 | _, err := os.Stat(socketPath()) 107 | return err == nil 108 | } 109 | 110 | func sendMessageToDaemon(cmd daemonCommand) (string, error) { 111 | if !socketExists() { 112 | return "", nil 113 | } 114 | 115 | fmt.Fprintf(os.Stderr, "Sending %s command to the daemon\n", cmd.Name) 116 | 117 | conn, err := net.Dial("unix", socketPath()) 118 | if err != nil { 119 | return "", err 120 | } 121 | defer conn.Close() 122 | 123 | if err := json.NewEncoder(conn).Encode(cmd); err != nil { 124 | return "", err 125 | } 126 | 127 | result, err := io.ReadAll(conn) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | // sanity check 133 | if len(result) == 0 { 134 | return "", fmt.Errorf("empty response from the daemon") 135 | } 136 | 137 | return string(result), nil 138 | } 139 | 140 | func main() { 141 | var rootCmd = &cobra.Command{ 142 | Use: "orches", 143 | Short: "A simple git-ops tool for Podman and systemd", 144 | Long: "orches is a git-ops tool for orchestrating Podman containers and systemd units on a single machine.", 145 | Version: version, 146 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 147 | level := slog.LevelInfo 148 | verbose, _ := cmd.Flags().GetBool("verbose") 149 | if verbose { 150 | level = slog.LevelDebug 151 | } 152 | logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 153 | Level: level, 154 | })) 155 | slog.SetDefault(logger) 156 | if verbose { 157 | slog.Debug("Verbose output enabled") 158 | } 159 | 160 | slog.Debug("Base directory", "path", baseDir) 161 | slog.Debug("uid", "uid", os.Getuid()) 162 | }, 163 | SilenceUsage: true, 164 | SilenceErrors: true, 165 | } 166 | rootCmd.PersistentFlags().Bool("dry", false, "Dry run") 167 | rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Verbose output") 168 | 169 | var initCmd = &cobra.Command{ 170 | Use: "init [remote]", 171 | Short: "Initialize by cloning a repo and setting up state", 172 | Long: "Initialize orches by cloning a Git repository and setting up the initial deployment state. The remote argument can be any valid Git repository URL or local path.", 173 | Example: " orches init https://github.com/user/repo.git\n" + 174 | " orches init /path/to/local/repo", 175 | Args: cobra.ExactArgs(1), 176 | RunE: func(cmd *cobra.Command, args []string) error { 177 | if socketExists() { 178 | return errors.New("daemon is already running, cannot init") 179 | } 180 | return initRepo(args[0], getRootFlags(cmd)) 181 | }, 182 | } 183 | 184 | var syncCmd = &cobra.Command{ 185 | Use: "sync", 186 | Short: "Sync deployments", 187 | Long: "Synchronize the local system state with the target repository's state. This will fetch the latest changes and apply them.", 188 | RunE: func(cmd *cobra.Command, args []string) error { 189 | dc := daemonCommand{Name: "sync"} 190 | remoteRes, err := sendMessageToDaemon(dc) 191 | if err != nil { 192 | return fmt.Errorf("failed to send message to daemon: %w", err) 193 | } 194 | if remoteRes != "" { 195 | fmt.Fprintf(os.Stderr, "Daemon responded: %s\n", remoteRes) 196 | return nil 197 | } 198 | 199 | _, err = cmdSync(getRootFlags(cmd)) 200 | return err 201 | }, 202 | } 203 | 204 | var pruneCmd = &cobra.Command{ 205 | Use: "prune", 206 | Short: "Prune deployments", 207 | Long: "Remove all deployed resources and clean up the local repository state. This will stop all managed services and containers.", 208 | RunE: func(cmd *cobra.Command, args []string) error { 209 | dc := daemonCommand{Name: "prune"} 210 | remoteRes, err := sendMessageToDaemon(dc) 211 | if err != nil { 212 | return fmt.Errorf("failed to send message to daemon: %w", err) 213 | } 214 | if remoteRes != "" { 215 | fmt.Fprintf(os.Stderr, "Daemon responded:\n%s\n", remoteRes) 216 | return nil 217 | } 218 | return cmdPrune(getRootFlags(cmd)) 219 | }, 220 | } 221 | 222 | var switchCmd = &cobra.Command{ 223 | Use: "switch [remote]", 224 | Short: "Switch to a different deployment", 225 | Long: "Switch the deployment source to a different Git repository. This will first prune the existing deployment and then initialize from the new source.", 226 | Example: " orches switch https://github.com/user/new-repo.git\n" + 227 | " orches switch /path/to/new/local/repo", 228 | Args: cobra.ExactArgs(1), 229 | RunE: func(cmd *cobra.Command, args []string) error { 230 | p := args[0] 231 | 232 | if git.IsLocalEndpoint(p) { 233 | var err error 234 | // absolute path is important for the daemon 235 | p, err = filepath.Abs(p) 236 | if err != nil { 237 | return fmt.Errorf("failed to get absolute path: %w", err) 238 | } 239 | } 240 | 241 | dc := daemonCommand{Name: "switch", Arg: p} 242 | remoteRes, err := sendMessageToDaemon(dc) 243 | if err != nil { 244 | return fmt.Errorf("failed to send message to daemon: %w", err) 245 | } 246 | if remoteRes != "" { 247 | fmt.Fprintf(os.Stderr, "Daemon responded:\n%s\n", remoteRes) 248 | return nil 249 | } 250 | 251 | return cmdSwitch(p, getRootFlags(cmd)) 252 | }, 253 | } 254 | 255 | var statusCmd = &cobra.Command{ 256 | Use: "status", 257 | Short: "Show the repository status", 258 | Long: "Display information about the current deployment, including the remote repository URL and the currently deployed Git reference.", 259 | RunE: func(cmd *cobra.Command, args []string) error { 260 | dc := daemonCommand{Name: "status"} 261 | remoteRes, err := sendMessageToDaemon(dc) 262 | if err != nil { 263 | return fmt.Errorf("failed to send message to daemon: %w", err) 264 | } 265 | if remoteRes != "" { 266 | fmt.Fprintf(os.Stderr, "Daemon responded:\n%s\n", remoteRes) 267 | return nil 268 | } 269 | 270 | if _, err := os.Stat(path.Join(baseDir, "repo")); errors.Is(err, os.ErrNotExist) { 271 | return errors.New("no repository found, initalize orches first") 272 | } 273 | result, err := cmdStatus() 274 | if err != nil { 275 | return err 276 | } 277 | 278 | fmt.Printf("%s\n", result) 279 | 280 | return nil 281 | }, 282 | } 283 | 284 | var runCmd = &cobra.Command{ 285 | Use: "run", 286 | Short: "Periodically sync deployments", 287 | Long: "Start the orches daemon that periodically synchronizes the local system with the remote repository.", 288 | Example: " orches run\n" + 289 | " orches run --interval 300", 290 | RunE: func(cmd *cobra.Command, args []string) error { 291 | syncInterval, err := cmd.Flags().GetInt("interval") 292 | if err != nil { 293 | return err 294 | } 295 | 296 | if _, err := os.Stat(path.Join(baseDir, "repo")); errors.Is(err, os.ErrNotExist) { 297 | return errors.New("no repository found, initalize orches first") 298 | } 299 | 300 | sock, err := net.Listen("unix", socketPath()) 301 | if err != nil { 302 | return fmt.Errorf("failed to start the daemon socket: %w", err) 303 | } 304 | defer sock.Close() 305 | 306 | cmdChan, statusChan := waitForCommands(sock) 307 | 308 | sig := make(chan os.Signal, 1) 309 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 310 | defer signal.Stop(sig) 311 | 312 | for { 313 | res, err := cmdSync(getRootFlags(cmd)) 314 | if err != nil { 315 | fmt.Fprintf(os.Stderr, "Error while running periodic sync: %v\n", err) 316 | } 317 | 318 | if res != nil && res.RestartNeeded { 319 | fmt.Fprintln(os.Stderr, "Restart needed after a periodical sync, exiting.") 320 | return nil 321 | } 322 | 323 | nextTick := time.After(time.Duration(syncInterval) * time.Second) 324 | 325 | innerLoop: 326 | for { 327 | select { 328 | case <-sig: 329 | fmt.Fprintln(os.Stderr, "Received interrupt signal, exiting.") 330 | return nil 331 | case c := <-cmdChan: 332 | switch c.Name { 333 | case "sync": 334 | res, err := cmdSync(getRootFlags(cmd)) 335 | if err != nil { 336 | statusChan <- fmt.Sprintf("%v", err) 337 | fmt.Fprintf(os.Stderr, "Remote sync command failed: %v\n", err) 338 | } else { 339 | statusChan <- "Synced" 340 | fmt.Fprintln(os.Stderr, "Remote sync command successfully processed.") 341 | } 342 | if res != nil && res.RestartNeeded { 343 | fmt.Fprintln(os.Stderr, "Restart needed after a remote sync, exiting.") 344 | return nil 345 | } 346 | case "prune": 347 | err := cmdPrune(getRootFlags(cmd)) 348 | if err != nil { 349 | statusChan <- fmt.Sprintf("%v", err) 350 | fmt.Fprintf(os.Stderr, "Remote prune command failed: %v\n", err) 351 | } else { 352 | statusChan <- "Pruned" 353 | fmt.Fprintln(os.Stderr, "Remote prune command successfully processed, exiting.") 354 | return nil 355 | } 356 | case "switch": 357 | err := cmdSwitch(c.Arg, getRootFlags(cmd)) 358 | if err != nil { 359 | statusChan <- fmt.Sprintf("%v", err) 360 | fmt.Fprintf(os.Stderr, "Remote switch (%s) command failed: %v\n", c.Arg, err) 361 | } else { 362 | statusChan <- fmt.Sprintf("Switched to %s", c.Arg) 363 | fmt.Fprintf(os.Stderr, "Remote switch (%s) command successfully processed, exiting.\n", c.Arg) 364 | return nil 365 | } 366 | case "status": 367 | res, err := cmdStatus() 368 | if err != nil { 369 | statusChan <- fmt.Sprintf("%v", err) 370 | fmt.Fprintf(os.Stderr, "Remote status command failed: %v\n", err) 371 | } else { 372 | statusChan <- res 373 | fmt.Fprintln(os.Stderr, "Remote status command successfully processed.") 374 | } 375 | default: 376 | statusChan <- "Unknown command" 377 | fmt.Fprintf(os.Stderr, "Received unknown remote command: %s\n", c.Name) 378 | } 379 | case <-nextTick: 380 | break innerLoop 381 | } 382 | } 383 | } 384 | }, 385 | } 386 | 387 | runCmd.Flags().Int("interval", 120, "Interval in seconds between synchronization attempts") 388 | 389 | var versionCmd = &cobra.Command{ 390 | Use: "version", 391 | Short: "Print the version", 392 | Long: "Display version information about orches, including the version number, Git reference, and build timestamp.", 393 | RunE: func(cmd *cobra.Command, args []string) error { 394 | info, ok := debug.ReadBuildInfo() 395 | if !ok { 396 | return errors.New("no build info available") 397 | } 398 | 399 | buildinfo := struct { 400 | version string 401 | ref string 402 | time string 403 | }{ 404 | version: version, 405 | ref: "unknown", 406 | time: "unknown", 407 | } 408 | 409 | for _, val := range info.Settings { 410 | switch val.Key { 411 | case "vcs.revision": 412 | buildinfo.ref = val.Value 413 | case "vcs.time": 414 | buildinfo.time = val.Value 415 | } 416 | } 417 | 418 | fmt.Printf("version: %s\n", buildinfo.version) 419 | fmt.Printf("gitref: %s\n", buildinfo.ref) 420 | fmt.Printf("buildtime: %s\n", buildinfo.time) 421 | 422 | return nil 423 | }, 424 | } 425 | 426 | rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { 427 | return fmt.Errorf("%w\nSee '%s --help'", err, cmd.CommandPath()) 428 | }) 429 | 430 | rootCmd.AddCommand(initCmd, syncCmd, pruneCmd, runCmd, switchCmd, statusCmd, versionCmd) 431 | 432 | if err := rootCmd.Execute(); err != nil { 433 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 434 | os.Exit(1) 435 | } 436 | } 437 | 438 | func lock(fn func() error) error { 439 | os.Mkdir(baseDir, 0755) 440 | 441 | slog.Debug("Adding interrupt signal handler in lock()") 442 | interruptSig := make(chan os.Signal, 1) 443 | signal.Notify(interruptSig, os.Interrupt, syscall.SIGTERM) 444 | 445 | defer func() { 446 | signal.Stop(interruptSig) 447 | slog.Debug("Removed interrupt signal handler in lock()") 448 | }() 449 | 450 | var f *os.File 451 | var err error 452 | for { 453 | f, err = os.OpenFile(path.Join(baseDir, "lock"), os.O_CREATE|os.O_EXCL, 0600) 454 | if err == nil { 455 | break 456 | } 457 | slog.Debug("Failed to acquire lock, retrying", "error", err) 458 | select { 459 | case <-time.After(100 * time.Millisecond): 460 | case <-interruptSig: 461 | return errors.New("interrupted while waiting for a lock") 462 | } 463 | } 464 | 465 | defer f.Close() 466 | defer func() { 467 | err := os.Remove(f.Name()) 468 | if err != nil { 469 | slog.Error("Failed to remove lock file", "error", err) 470 | } 471 | slog.Debug("Removed lock") 472 | }() 473 | 474 | slog.Debug("Acquired lock") 475 | 476 | return fn() 477 | } 478 | 479 | func initRepo(remote string, flags rootFlags) error { 480 | return lock(func() error { 481 | return doInit(remote, flags.dryRun) 482 | }) 483 | } 484 | 485 | func doInit(remote string, dryRun bool) error { 486 | repoPath := filepath.Join(baseDir, "repo") 487 | 488 | if _, err := os.Stat(repoPath); !errors.Is(err, os.ErrNotExist) { 489 | return fmt.Errorf("repository already exists at %s", repoPath) 490 | } 491 | 492 | if _, err := git.Clone(remote, repoPath); err != nil { 493 | return fmt.Errorf("failed to clone repo: %w", err) 494 | } 495 | 496 | blank, err := os.MkdirTemp("", "orches-initial-sync-") 497 | if err != nil { 498 | return fmt.Errorf("failed to create temporary directory: %w", err) 499 | } 500 | defer os.RemoveAll(blank) 501 | 502 | if _, err := syncer.SyncDirs(blank, repoPath, dryRun, nil); err != nil { 503 | return fmt.Errorf("failed to sync directories: %w", err) 504 | } 505 | 506 | if dryRun { 507 | if err := os.RemoveAll(baseDir); err != nil { 508 | return fmt.Errorf("failed to remove directory: %w", err) 509 | } 510 | return nil 511 | } 512 | 513 | fmt.Fprintf(os.Stderr, "Initialized repo from %s\n", remote) 514 | return nil 515 | } 516 | 517 | func cmdSync(flags rootFlags) (*syncer.SyncResult, error) { 518 | var res *syncer.SyncResult 519 | 520 | err := lock(func() error { 521 | repoDir := filepath.Join(baseDir, "repo") 522 | repo := git.Repo{Path: repoDir} 523 | 524 | currentLocalRef, err := repo.Ref("HEAD") 525 | if err != nil { 526 | return fmt.Errorf("failed to get current HEAD ref: %w", err) 527 | } 528 | 529 | fmt.Fprintf(os.Stderr, "Fetching from origin\n") 530 | if err := repo.Fetch("origin"); err != nil { 531 | return fmt.Errorf("failed to fetch from origin: %w", err) 532 | } 533 | 534 | remoteUpstreamRef, err := repo.Ref("@{u}") 535 | if err != nil { 536 | return fmt.Errorf("failed to get upstream ref (@{u}): %w. Ensure your current branch is tracking an upstream branch", err) 537 | } 538 | 539 | syncPostSyncAction := func(isDryRun bool) error { 540 | if !isDryRun { 541 | slog.Info("PostSyncAction(cmdSync): Resetting repository", "ref", remoteUpstreamRef) 542 | if err := repo.Reset(remoteUpstreamRef); err != nil { 543 | return fmt.Errorf("failed to reset repository to %s: %w", remoteUpstreamRef, err) 544 | } 545 | fmt.Fprintf(os.Stderr, "Repository reset to %s\n", remoteUpstreamRef) 546 | } else { 547 | fmt.Fprintf(os.Stderr, "PostSyncAction(cmdSync): Dry run, repository would have been reset to %s\n", remoteUpstreamRef) 548 | } 549 | return nil 550 | } 551 | 552 | if currentLocalRef == remoteUpstreamRef { 553 | fmt.Fprintln(os.Stderr, "No new commits to sync.") 554 | return nil 555 | } 556 | 557 | fmt.Fprintf(os.Stderr, "Current HEAD is %s, targeting %s\n", currentLocalRef, remoteUpstreamRef) 558 | 559 | oldState, err := repo.NewWorktree(currentLocalRef) 560 | if err != nil { 561 | return fmt.Errorf("failed to create worktree for current state: %w", err) 562 | } 563 | defer oldState.Cleanup() 564 | 565 | newState, err := repo.NewWorktree(remoteUpstreamRef) 566 | if err != nil { 567 | return fmt.Errorf("failed to create worktree for new state: %w", err) 568 | } 569 | defer newState.Cleanup() 570 | 571 | fmt.Fprintf(os.Stderr, "Syncing changes between %s and %s\n", currentLocalRef, remoteUpstreamRef) 572 | 573 | res, err = syncer.SyncDirs(oldState.Path, newState.Path, flags.dryRun, syncPostSyncAction) 574 | if err != nil { 575 | slog.Error("Sync process failed", "error", err, "current_ref", currentLocalRef) 576 | return fmt.Errorf("failed to sync directories: %w", err) 577 | } 578 | 579 | fmt.Fprintf(os.Stderr, "Synced to %s\n", remoteUpstreamRef) 580 | return nil 581 | }) 582 | return res, err 583 | } 584 | 585 | func cmdPrune(flags rootFlags) error { 586 | return lock(func() error { 587 | return doPrune(flags.dryRun) 588 | }) 589 | } 590 | 591 | func doPrune(dryRun bool) error { 592 | repoDir := filepath.Join(baseDir, "repo") 593 | if _, err := os.Stat(repoDir); errors.Is(err, os.ErrNotExist) { 594 | return errors.New("no repository to prune, orches not initialized") 595 | } 596 | 597 | blank, err := os.MkdirTemp("", "orches-prune-") 598 | if err != nil { 599 | return fmt.Errorf("failed to create temporary directory: %w", err) 600 | } 601 | defer os.RemoveAll(blank) 602 | 603 | prunePostSyncAction := func(isDryRun bool) error { 604 | if !isDryRun { 605 | slog.Info("PostSyncAction(doPrune): Removing repository directory", "path", repoDir) 606 | if err := os.RemoveAll(repoDir); err != nil { 607 | return fmt.Errorf("failed to remove repository directory %s: %w", repoDir, err) 608 | } 609 | fmt.Fprintf(os.Stderr, "Repository pruned from %s\n", repoDir) 610 | } else { 611 | fmt.Fprintf(os.Stderr, "PostSyncAction(doPrune): Dry run, would remove repository directory %s\n", repoDir) 612 | } 613 | return nil 614 | } 615 | 616 | if _, err := syncer.SyncDirs(repoDir, blank, dryRun, prunePostSyncAction); err != nil { 617 | return fmt.Errorf("failed to sync directories for prune: %w", err) 618 | } 619 | 620 | fmt.Fprintf(os.Stderr, "Repository pruned\n") 621 | return nil 622 | } 623 | 624 | func cmdSwitch(remote string, flags rootFlags) error { 625 | return lock(func() error { 626 | // First prune the existing deployment 627 | if err := doPrune(flags.dryRun); err != nil { 628 | return fmt.Errorf("failed to prune existing deployment: %w", err) 629 | } 630 | 631 | // Then initialize with the new remote 632 | if err := doInit(remote, flags.dryRun); err != nil { 633 | return fmt.Errorf("failed to initialize new deployment: %w", err) 634 | } 635 | 636 | return nil 637 | }) 638 | } 639 | 640 | func cmdStatus() (string, error) { 641 | repoDir := path.Join(baseDir, "repo") 642 | if _, err := os.Stat(repoDir); errors.Is(err, os.ErrNotExist) { 643 | return "", errors.New("no repository found, initalize orches first") 644 | } 645 | 646 | repo := git.Repo{Path: repoDir} 647 | 648 | remoteURL, err := repo.RemoteURL("origin") 649 | if err != nil { 650 | return "", fmt.Errorf("failed to get remote URL: %w", err) 651 | } 652 | 653 | head, err := repo.Ref("HEAD") 654 | if err != nil { 655 | return "", fmt.Errorf("failed to get HEAD: %w", err) 656 | } 657 | 658 | buf := fmt.Sprintf("remote: %s\nref: %s", remoteURL, head) 659 | return buf, nil 660 | } 661 | --------------------------------------------------------------------------------