Loading CONTRIBUTING.md +7 −13 Changes for CONTRIBUTING.md: 7 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -175,11 +175,11 @@ Run integration/deploy commands from the repository root on the Linux host: cd /path/to/Felis ``` The one-line bootstrap script is intended for a clean Linux host, not a typical macOS development machine: The setup TUI is intended for a clean Linux host, not a typical macOS development machine: ```bash sudo -E bash deploy/bootstrap.sh sudo felis setup ``` Useful overrides: Loading @@ -191,16 +191,10 @@ export FELIS_IMAGE=felis:dev export FELIS_ROOT_DOMAIN=<node-ip>.nip.io ``` The bootstrap flow installs/configures system services, builds the Felis image, imports it into k3s, runs migrations, applies CRDs/manifests, and deploys the control plane. After bootstrap, use the break-glass console to create or recover the Owner account: ```bash sudo felis breakGlass ``` The setup flow wraps the host bootstrap, then continues to Owner account setup and optional Cloudflare edge setup in the same command. The raw `deploy/bootstrap.sh` script remains available for low-level host provisioning when debugging the installer itself. Use a VM or disposable Linux server for this. Treat it as an integration and acceptance environment, while keeping normal coding and quick tests local. Loading Dockerfile +20 −11 Changes for Dockerfile: 20 added lines, 11 removed lines. Original line number Diff line number Diff line # Felis control-plane image. # # Builds the single multi-call `felis` binary (api / operator / migrate / reaper # / restore / manifests / setup) as a static, CGO-free executable and ships it on # a distroless base. Two contracts the rendered Deployments depend on: # Builds the panel, then the single multi-call `felis` binary (api / operator / # migrate / reaper / restore / manifests / setup) as a static, CGO-free # executable and ships it on a distroless base. Two contracts the rendered # Deployments depend on: # # 1. The binary lives on PATH at /usr/local/bin/felis, because the bundle # invokes it by bare name (`command: ["felis", ...]` in workloads.go). PATH # is pinned explicitly so this holds regardless of base-image defaults. # 1. The binary lives at /usr/local/bin/felis, and rendered Kubernetes # workloads invoke that absolute path rather than relying on PATH lookup. # 2. The image is meant to be imported into a local containerd (k3s ctr import) # and referenced by a NON-:latest tag (e.g. felis:demo). k8s then resolves # the default IfNotPresent pull policy against the imported image instead of Loading @@ -15,21 +15,30 @@ # deploy/bootstrap.sh also extracts this same binary onto the host (docker cp) # so `felis migrate up` and the `felis setup` TUI run with the identical build. FROM node:22-bookworm AS panel WORKDIR /panel COPY panel/package*.json ./ RUN npm ci COPY panel/ ./ RUN npm run build FROM golang:1.26 AS build WORKDIR /src ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ARG TARGETOS=linux ARG TARGETARCH # Prime the module cache first so source-only edits do not re-download deps. COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis COPY --from=panel /panel/dist ./internal/panel/static RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \ go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis FROM gcr.io/distroless/static-debian12:nonroot # Guarantee bare `felis` resolves no matter what PATH the base image ships. ENV PATH=/usr/local/bin:/usr/bin:/bin COPY --from=build /out/felis /usr/local/bin/felis COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis # distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins # runAsUser 1000 at deploy time, and a static binary needs no /etc/passwd entry, # so either uid runs the same binary from a read-only root filesystem. USER 65532:65532 ENTRYPOINT ["felis"] ENTRYPOINT ["/usr/local/bin/felis"] bootstrap_asset.go 0 → 100644 +20 −0 Changes for bootstrap_asset.go: 20 added lines, 0 removed lines. Original line number Diff line number Diff line package felis import "embed" //go:embed deploy/bootstrap.sh var bootstrapScript string //go:embed deploy/crd/*.yaml var bootstrapAssets embed.FS // BootstrapScript returns the host bootstrap installer embedded in the felis binary. func BootstrapScript() string { return bootstrapScript } // MinecraftServerCRD returns the embedded MinecraftServer CRD YAML used by the // host bootstrap path that runs without a source checkout. func MinecraftServerCRD() ([]byte, error) { return bootstrapAssets.ReadFile("deploy/crd/felis.lolicon.best_minecraftservers.yaml") } cmd/felis/api.go +23 −2 Changes for cmd/felis/api.go: 23 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -13,6 +13,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/restore" "felis.lolicon.best/internal/store" "felis.lolicon.best/internal/submit" Loading @@ -34,9 +35,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") internalAddr := fs.String("internal-addr", ":8081", "internal-face listen address (service token, no Zero Trust)") httpsAddr := fs.String("https-addr", "", "external HTTPS listen address (disabled unless --tls-cert and --tls-key are also set)") tlsCert := fs.String("tls-cert", "", "TLS certificate path for --https-addr") tlsKey := fs.String("tls-key", "", "TLS private key path for --https-addr") if err := fs.Parse(args); err != nil { return 2 } if (*httpsAddr == "") != (*tlsCert == "" || *tlsKey == "") { fmt.Fprintln(stderr, "felis api: --https-addr requires both --tls-cert and --tls-key") return 2 } cfg, err := config.Load(*cfgPath) if err != nil { Loading Loading @@ -157,13 +165,23 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)") externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain) internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()} externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: a.ExternalHandler()} externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: externalHandler} errc := make(chan error, 2) errc := make(chan error, 3) go func() { errc <- internalSrv.ListenAndServe() }() go func() { errc <- externalSrv.ListenAndServe() }() var httpsSrv *http.Server if *httpsAddr != "" { httpsSrv = &http.Server{Addr: *httpsAddr, Handler: externalHandler} go func() { errc <- httpsSrv.ListenAndServeTLS(*tlsCert, *tlsKey) }() } if httpsSrv != nil { fmt.Fprintf(stdout, "felis api: internal=%s external=%s https=%s\n", *internalAddr, cfg.Server.Listen, *httpsAddr) } else { fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen) } // reconcileBuilds drives the scan-gate translation: poll unfinished builds // and advance any whose Job has reached a terminal phase. GET on a build also Loading @@ -176,6 +194,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { defer cancel() _ = internalSrv.Shutdown(shutdownCtx) _ = externalSrv.Shutdown(shutdownCtx) if httpsSrv != nil { _ = httpsSrv.Shutdown(shutdownCtx) } return 0 case err := <-errc: if err != nil && err != http.ErrServerClosed { Loading cmd/felis/bootstrap_assets.go 0 → 100644 +25 −0 Changes for cmd/felis/bootstrap_assets.go: 25 added lines, 0 removed lines. Original line number Diff line number Diff line package main import ( "fmt" "io" felis "felis.lolicon.best" ) func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int { if len(args) != 1 || args[0] != "crd" { fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd") return 2 } crd, err := felis.MinecraftServerCRD() if err != nil { fmt.Fprintf(stderr, "felis bootstrap-assets: %v\n", err) return 1 } if _, err := stdout.Write(crd); err != nil { fmt.Fprintf(stderr, "felis bootstrap-assets: write: %v\n", err) return 1 } return 0 } Loading
CONTRIBUTING.md +7 −13 Changes for CONTRIBUTING.md: 7 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -175,11 +175,11 @@ Run integration/deploy commands from the repository root on the Linux host: cd /path/to/Felis ``` The one-line bootstrap script is intended for a clean Linux host, not a typical macOS development machine: The setup TUI is intended for a clean Linux host, not a typical macOS development machine: ```bash sudo -E bash deploy/bootstrap.sh sudo felis setup ``` Useful overrides: Loading @@ -191,16 +191,10 @@ export FELIS_IMAGE=felis:dev export FELIS_ROOT_DOMAIN=<node-ip>.nip.io ``` The bootstrap flow installs/configures system services, builds the Felis image, imports it into k3s, runs migrations, applies CRDs/manifests, and deploys the control plane. After bootstrap, use the break-glass console to create or recover the Owner account: ```bash sudo felis breakGlass ``` The setup flow wraps the host bootstrap, then continues to Owner account setup and optional Cloudflare edge setup in the same command. The raw `deploy/bootstrap.sh` script remains available for low-level host provisioning when debugging the installer itself. Use a VM or disposable Linux server for this. Treat it as an integration and acceptance environment, while keeping normal coding and quick tests local. Loading
Dockerfile +20 −11 Changes for Dockerfile: 20 added lines, 11 removed lines. Original line number Diff line number Diff line # Felis control-plane image. # # Builds the single multi-call `felis` binary (api / operator / migrate / reaper # / restore / manifests / setup) as a static, CGO-free executable and ships it on # a distroless base. Two contracts the rendered Deployments depend on: # Builds the panel, then the single multi-call `felis` binary (api / operator / # migrate / reaper / restore / manifests / setup) as a static, CGO-free # executable and ships it on a distroless base. Two contracts the rendered # Deployments depend on: # # 1. The binary lives on PATH at /usr/local/bin/felis, because the bundle # invokes it by bare name (`command: ["felis", ...]` in workloads.go). PATH # is pinned explicitly so this holds regardless of base-image defaults. # 1. The binary lives at /usr/local/bin/felis, and rendered Kubernetes # workloads invoke that absolute path rather than relying on PATH lookup. # 2. The image is meant to be imported into a local containerd (k3s ctr import) # and referenced by a NON-:latest tag (e.g. felis:demo). k8s then resolves # the default IfNotPresent pull policy against the imported image instead of Loading @@ -15,21 +15,30 @@ # deploy/bootstrap.sh also extracts this same binary onto the host (docker cp) # so `felis migrate up` and the `felis setup` TUI run with the identical build. FROM node:22-bookworm AS panel WORKDIR /panel COPY panel/package*.json ./ RUN npm ci COPY panel/ ./ RUN npm run build FROM golang:1.26 AS build WORKDIR /src ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ARG TARGETOS=linux ARG TARGETARCH # Prime the module cache first so source-only edits do not re-download deps. COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis COPY --from=panel /panel/dist ./internal/panel/static RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \ go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis FROM gcr.io/distroless/static-debian12:nonroot # Guarantee bare `felis` resolves no matter what PATH the base image ships. ENV PATH=/usr/local/bin:/usr/bin:/bin COPY --from=build /out/felis /usr/local/bin/felis COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis # distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins # runAsUser 1000 at deploy time, and a static binary needs no /etc/passwd entry, # so either uid runs the same binary from a read-only root filesystem. USER 65532:65532 ENTRYPOINT ["felis"] ENTRYPOINT ["/usr/local/bin/felis"]
bootstrap_asset.go 0 → 100644 +20 −0 Changes for bootstrap_asset.go: 20 added lines, 0 removed lines. Original line number Diff line number Diff line package felis import "embed" //go:embed deploy/bootstrap.sh var bootstrapScript string //go:embed deploy/crd/*.yaml var bootstrapAssets embed.FS // BootstrapScript returns the host bootstrap installer embedded in the felis binary. func BootstrapScript() string { return bootstrapScript } // MinecraftServerCRD returns the embedded MinecraftServer CRD YAML used by the // host bootstrap path that runs without a source checkout. func MinecraftServerCRD() ([]byte, error) { return bootstrapAssets.ReadFile("deploy/crd/felis.lolicon.best_minecraftservers.yaml") }
cmd/felis/api.go +23 −2 Changes for cmd/felis/api.go: 23 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -13,6 +13,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/restore" "felis.lolicon.best/internal/store" "felis.lolicon.best/internal/submit" Loading @@ -34,9 +35,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") internalAddr := fs.String("internal-addr", ":8081", "internal-face listen address (service token, no Zero Trust)") httpsAddr := fs.String("https-addr", "", "external HTTPS listen address (disabled unless --tls-cert and --tls-key are also set)") tlsCert := fs.String("tls-cert", "", "TLS certificate path for --https-addr") tlsKey := fs.String("tls-key", "", "TLS private key path for --https-addr") if err := fs.Parse(args); err != nil { return 2 } if (*httpsAddr == "") != (*tlsCert == "" || *tlsKey == "") { fmt.Fprintln(stderr, "felis api: --https-addr requires both --tls-cert and --tls-key") return 2 } cfg, err := config.Load(*cfgPath) if err != nil { Loading Loading @@ -157,13 +165,23 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)") externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain) internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()} externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: a.ExternalHandler()} externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: externalHandler} errc := make(chan error, 2) errc := make(chan error, 3) go func() { errc <- internalSrv.ListenAndServe() }() go func() { errc <- externalSrv.ListenAndServe() }() var httpsSrv *http.Server if *httpsAddr != "" { httpsSrv = &http.Server{Addr: *httpsAddr, Handler: externalHandler} go func() { errc <- httpsSrv.ListenAndServeTLS(*tlsCert, *tlsKey) }() } if httpsSrv != nil { fmt.Fprintf(stdout, "felis api: internal=%s external=%s https=%s\n", *internalAddr, cfg.Server.Listen, *httpsAddr) } else { fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen) } // reconcileBuilds drives the scan-gate translation: poll unfinished builds // and advance any whose Job has reached a terminal phase. GET on a build also Loading @@ -176,6 +194,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { defer cancel() _ = internalSrv.Shutdown(shutdownCtx) _ = externalSrv.Shutdown(shutdownCtx) if httpsSrv != nil { _ = httpsSrv.Shutdown(shutdownCtx) } return 0 case err := <-errc: if err != nil && err != http.ErrServerClosed { Loading
cmd/felis/bootstrap_assets.go 0 → 100644 +25 −0 Changes for cmd/felis/bootstrap_assets.go: 25 added lines, 0 removed lines. Original line number Diff line number Diff line package main import ( "fmt" "io" felis "felis.lolicon.best" ) func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int { if len(args) != 1 || args[0] != "crd" { fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd") return 2 } crd, err := felis.MinecraftServerCRD() if err != nil { fmt.Fprintf(stderr, "felis bootstrap-assets: %v\n", err) return 1 } if _, err := stdout.Write(crd); err != nil { fmt.Fprintf(stderr, "felis bootstrap-assets: write: %v\n", err) return 1 } return 0 }