diff --git a/go.mod b/go.mod index 6547d5d..90eec15 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/cloudflare/cloudflare-go v0.116.0 github.com/creack/pty v1.1.24 github.com/digitalocean/godo v1.171.0 + github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 github.com/go-sql-driver/mysql v1.9.3 @@ -20,13 +21,14 @@ require ( github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 github.com/robfig/cron/v3 v3.0.1 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.44.0 golang.org/x/oauth2 v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect @@ -40,9 +42,19 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect @@ -52,18 +64,31 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index d6ec36f..0489ad7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= @@ -33,12 +37,21 @@ github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4p github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cloudflare/cloudflare-go v0.116.0 h1:iRPMnTtnswRpELO65NTwMX4+RTdxZl+Xf/zi+HPE95s= github.com/cloudflare/cloudflare-go v0.116.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,8 +59,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitalocean/godo v1.171.0 h1:QwpkwWKr3v7yxc8D4NQG973NoR9APCEWjYnLOQeXVpQ= github.com/digitalocean/godo v1.171.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -56,6 +79,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -70,11 +98,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -82,6 +108,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -93,8 +121,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= @@ -107,19 +135,37 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= -github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -130,37 +176,64 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/server.go b/internal/api/server.go index a4011e2..a45cfac 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,6 +30,7 @@ import ( "github.com/flatrun/agent/internal/proxy" "github.com/flatrun/agent/internal/scheduler" "github.com/flatrun/agent/internal/security" + "github.com/flatrun/agent/internal/setup" "github.com/flatrun/agent/internal/system" "github.com/flatrun/agent/internal/traffic" "github.com/flatrun/agent/pkg/config" @@ -68,6 +69,8 @@ type Server struct { auditManager *audit.Manager auditMiddleware *audit.Middleware powerDNSManager *dns.PowerDNSManager + setupManager *setup.Manager + setupHandlers *setup.Handlers } func New(cfg *config.Config, configPath string) *Server { @@ -79,8 +82,15 @@ func New(cfg *config.Config, configPath string) *Server { router := gin.Default() + var setupManager *setup.Manager + var setupManagerErr error + setupManager, setupManagerErr = setup.NewManager(cfg.DeploymentsPath, cfg, configPath) + if setupManagerErr != nil { + log.Printf("Warning: Failed to initialize setup manager: %v", setupManagerErr) + } + if cfg.API.EnableCORS { - router.Use(corsMiddleware(cfg.API.AllowedOrigins)) + router.Use(dynamicCorsMiddleware(cfg, setupManager)) } manager := docker.NewManager(cfg.DeploymentsPath) @@ -170,6 +180,11 @@ func New(cfg *config.Config, configPath string) *Server { powerDNSManager := dns.NewPowerDNSManager(cfg) + var setupHandlers *setup.Handlers + if setupManager != nil { + setupHandlers = setup.NewHandlers(setupManager, authManager) + } + s := &Server{ config: cfg, configPath: configPath, @@ -192,6 +207,8 @@ func New(cfg *config.Config, configPath string) *Server { auditManager: auditManager, auditMiddleware: auditMiddleware, powerDNSManager: powerDNSManager, + setupManager: setupManager, + setupHandlers: setupHandlers, } if backupManager != nil { @@ -218,6 +235,26 @@ func (s *Server) setupRoutes() { api.POST("/auth/login", s.authMiddleware.Login) api.GET("/auth/validate", s.authMiddleware.ValidateToken) + if s.setupHandlers != nil { + setupGroup := api.Group("/setup") + { + setupGroup.GET("/status", s.setupHandlers.GetStatus) + setupGroup.GET("/verify-dns", s.setupHandlers.VerifyDNS) + + setupProtected := setupGroup.Group("") + setupProtected.Use(s.setupHandlers.RequireSetupIncomplete()) + { + setupProtected.POST("/initialize", s.setupHandlers.Initialize) + setupProtected.POST("/validate", s.setupHandlers.RunValidation) + setupProtected.POST("/domain", s.setupHandlers.ConfigureDomain) + setupProtected.POST("/cors", s.setupHandlers.ConfigureCORS) + setupProtected.POST("/user", s.setupHandlers.CreateUser) + setupProtected.POST("/install-ui", s.setupHandlers.InstallUI) + setupProtected.POST("/complete", s.setupHandlers.Complete) + } + } + } + // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) @@ -4110,6 +4147,35 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc { } } +func dynamicCorsMiddleware(cfg *config.Config, setupMgr *setup.Manager) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + + allowedOrigins := cfg.API.AllowedOrigins + if setupMgr != nil { + allowedOrigins = setupMgr.GetAllowedOrigins() + } + + for _, allowed := range allowedOrigins { + if origin == allowed || allowed == "*" { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + break + } + } + + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + func (s *Server) listDeploymentFiles(c *gin.Context) { name := c.Param("name") path := c.DefaultQuery("path", "/") diff --git a/internal/setup/handlers.go b/internal/setup/handlers.go new file mode 100644 index 0000000..c5ab6f3 --- /dev/null +++ b/internal/setup/handlers.go @@ -0,0 +1,285 @@ +package setup + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/flatrun/agent/internal/auth" + "github.com/gin-gonic/gin" +) + +type Handlers struct { + manager *Manager + authManager *auth.Manager +} + +func NewHandlers(manager *Manager, authManager *auth.Manager) *Handlers { + return &Handlers{ + manager: manager, + authManager: authManager, + } +} + +func (h *Handlers) GetStatus(c *gin.Context) { + status, err := h.manager.GetStatus() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, status) +} + +func (h *Handlers) Initialize(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + var req struct { + Mode string `json:"mode" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + mode := DeploymentMode(req.Mode) + if mode != ModeFull && mode != ModeAgentOnly { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid mode. Must be 'full' or 'agent-only'"}) + return + } + + resp, err := h.manager.Initialize(mode) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) RunValidation(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + checks := h.manager.RunValidation() + + allPassed := true + for _, check := range checks { + if check.Required && check.Status == StatusFail { + allPassed = false + break + } + } + + c.JSON(http.StatusOK, gin.H{ + "checks": checks, + "all_passed": allPassed, + }) +} + +func (h *Handlers) ConfigureDomain(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + var req struct { + Domain string `json:"domain" binding:"required"` + AutoSSL bool `json:"auto_ssl"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + domain := strings.TrimSpace(req.Domain) + domain = strings.TrimPrefix(domain, "http://") + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimSuffix(domain, "/") + + if domain == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Domain is required"}) + return + } + + if err := h.manager.ConfigureDomain(domain, req.AutoSSL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "domain": domain, + "auto_ssl": req.AutoSSL, + "message": "Domain configured successfully", + }) +} + +func (h *Handlers) VerifyDNS(c *gin.Context) { + domain := c.Query("domain") + if domain == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Domain parameter is required"}) + return + } + + domain = strings.TrimSpace(domain) + domain = strings.TrimPrefix(domain, "http://") + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimSuffix(domain, "/") + + result, err := h.manager.VerifyDNS(domain) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +func (h *Handlers) ConfigureCORS(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + var req struct { + UIOrigin string `json:"ui_origin" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + origin := strings.TrimSpace(req.UIOrigin) + origin = strings.TrimSuffix(origin, "/") + + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid URL format. Must include scheme (http/https)"}) + return + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL scheme must be http or https"}) + return + } + + if err := h.manager.ConfigureCORS(origin); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ui_origin": origin, + "message": "CORS configuration saved", + }) +} + +func (h *Handlers) CreateUser(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + if h.authManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Authentication module not enabled"}) + return + } + + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(req.Username) < 3 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username must be at least 3 characters"}) + return + } + + if len(req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) + return + } + + user, err := h.authManager.CreateUser(req.Username, req.Email, req.Password, auth.RoleAdmin, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + apiKey, plainKey, err := h.authManager.CreateAPIKey( + user.ID, + "Setup API Key", + "Auto-generated during initial setup", + auth.RoleAdmin, + nil, + nil, + time.Time{}, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "User created but failed to generate API key: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": user.ID, + "username": user.Username, + "api_key": plainKey, + "api_key_id": apiKey.KeyID, + "message": "User and API key created successfully", + }) +} + +func (h *Handlers) Complete(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + if err := h.manager.Complete(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Setup completed successfully", + "initialized": true, + }) +} + +func (h *Handlers) InstallUI(c *gin.Context) { + if h.manager.IsInitialized() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Setup already completed"}) + return + } + + if err := h.manager.InstallUI(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "UI installed successfully", + }) +} + +func (h *Handlers) RequireSetupIncomplete() gin.HandlerFunc { + return func(c *gin.Context) { + if h.manager.IsInitialized() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Setup has been completed. These endpoints are no longer available.", + }) + return + } + c.Next() + } +} diff --git a/internal/setup/manager.go b/internal/setup/manager.go new file mode 100644 index 0000000..ee3e8f7 --- /dev/null +++ b/internal/setup/manager.go @@ -0,0 +1,447 @@ +package setup + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/flatrun/agent/pkg/config" +) + +type DeploymentMode string + +const ( + ModeFull DeploymentMode = "full" + ModeAgentOnly DeploymentMode = "agent-only" +) + +type Manager struct { + mu sync.RWMutex + db *DB + config *config.Config + configPath string +} + +type SetupStatus struct { + Initialized bool `json:"initialized"` + InstanceIP string `json:"instance_ip"` + AgentVersion string `json:"agent_version"` + DeploymentMode string `json:"deployment_mode,omitempty"` + UIOrigin string `json:"ui_origin,omitempty"` + Domain string `json:"domain,omitempty"` + CloudProvider string `json:"cloud_provider,omitempty"` +} + +type InitResponse struct { + JWTSecret string `json:"jwt_secret"` + Mode string `json:"mode"` +} + +type DNSCheckResult struct { + Domain string `json:"domain"` + Expected string `json:"expected"` + Actual []string `json:"actual"` + Valid bool `json:"valid"` + Message string `json:"message"` +} + +type UserResponse struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + APIKey string `json:"api_key"` +} + +var Version = "dev" + +func NewManager(deploymentsPath string, cfg *config.Config, configPath string) (*Manager, error) { + db, err := NewSetupDB(deploymentsPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize setup database: %w", err) + } + + m := &Manager{ + db: db, + config: cfg, + configPath: configPath, + } + + if err := m.detectEnvironment(); err != nil { + log.Printf("Warning: failed to detect environment: %v", err) + } + + return m, nil +} + +func (m *Manager) Close() error { + return m.db.Close() +} + +func (m *Manager) IsInitialized() bool { + state, err := m.db.GetState() + if err != nil { + log.Printf("Warning: failed to get setup state: %v", err) + return false + } + return state.Initialized +} + +func (m *Manager) GetStatus() (*SetupStatus, error) { + state, err := m.db.GetState() + if err != nil { + return nil, err + } + + return &SetupStatus{ + Initialized: state.Initialized, + InstanceIP: state.InstanceIP, + AgentVersion: Version, + DeploymentMode: state.DeploymentMode, + UIOrigin: state.UIOrigin, + Domain: state.Domain, + CloudProvider: state.CloudProvider, + }, nil +} + +func (m *Manager) Initialize(mode DeploymentMode) (*InitResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + + state, err := m.db.GetState() + if err != nil { + return nil, err + } + + if state.Initialized { + return nil, fmt.Errorf("setup already completed") + } + + jwtSecret := generateSecret(32) + if err := m.db.SetJWTSecret(jwtSecret); err != nil { + return nil, fmt.Errorf("failed to save JWT secret: %w", err) + } + + if err := m.db.SetDeploymentMode(string(mode)); err != nil { + return nil, fmt.Errorf("failed to save deployment mode: %w", err) + } + + m.config.Auth.JWTSecret = jwtSecret + if err := config.Save(m.config, m.configPath); err != nil { + log.Printf("Warning: failed to save config: %v", err) + } + + return &InitResponse{ + JWTSecret: jwtSecret, + Mode: string(mode), + }, nil +} + +func (m *Manager) ConfigureDomain(domain string, autoSSL bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.db.SetDomain(domain, autoSSL); err != nil { + return fmt.Errorf("failed to save domain: %w", err) + } + + m.config.Domain.DefaultDomain = domain + m.config.Domain.AutoSSL = autoSSL + if err := config.Save(m.config, m.configPath); err != nil { + log.Printf("Warning: failed to save config: %v", err) + } + + return nil +} + +func (m *Manager) ConfigureCORS(uiOrigin string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.db.SetUIOrigin(uiOrigin); err != nil { + return fmt.Errorf("failed to save UI origin: %w", err) + } + + m.config.API.EnableCORS = true + found := false + for _, origin := range m.config.API.AllowedOrigins { + if origin == uiOrigin { + found = true + break + } + } + if !found { + m.config.API.AllowedOrigins = append(m.config.API.AllowedOrigins, uiOrigin) + } + + if err := config.Save(m.config, m.configPath); err != nil { + log.Printf("Warning: failed to save config: %v", err) + } + + return nil +} + +func (m *Manager) VerifyDNS(domain string) (*DNSCheckResult, error) { + state, err := m.db.GetState() + if err != nil { + return nil, err + } + + expectedIP := state.InstanceIP + if expectedIP == "" { + return nil, fmt.Errorf("instance IP not detected") + } + + ips, err := net.LookupIP(domain) + if err != nil { + return &DNSCheckResult{ + Domain: domain, + Expected: expectedIP, + Actual: []string{}, + Valid: false, + Message: fmt.Sprintf("DNS lookup failed: %v", err), + }, nil + } + + var resolvedIPs []string + valid := false + for _, ip := range ips { + ipStr := ip.String() + resolvedIPs = append(resolvedIPs, ipStr) + if ipStr == expectedIP { + valid = true + } + } + + result := &DNSCheckResult{ + Domain: domain, + Expected: expectedIP, + Actual: resolvedIPs, + Valid: valid, + } + + if valid { + result.Message = "DNS verification successful" + } else { + result.Message = fmt.Sprintf("Domain does not point to instance IP. Expected %s, got %v", expectedIP, resolvedIPs) + } + + return result, nil +} + +func (m *Manager) Complete() error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.db.MarkInitialized(); err != nil { + return fmt.Errorf("failed to mark setup as complete: %w", err) + } + + return nil +} + +func (m *Manager) GetAllowedOrigins() []string { + state, err := m.db.GetState() + if err != nil { + return m.config.API.AllowedOrigins + } + + origins := make([]string, len(m.config.API.AllowedOrigins)) + copy(origins, m.config.API.AllowedOrigins) + + if state.UIOrigin != "" { + found := false + for _, o := range origins { + if o == state.UIOrigin { + found = true + break + } + } + if !found { + origins = append(origins, state.UIOrigin) + } + } + + return origins +} + +func (m *Manager) detectEnvironment() error { + ip, provider := m.detectCloudEnvironment() + + if ip != "" { + if err := m.db.SetInstanceIP(ip); err != nil { + return err + } + } + + if provider != "" { + if err := m.db.SetCloudProvider(provider); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) detectCloudEnvironment() (string, string) { + type cloudMetadata struct { + name string + ipURL string + idURL string + header map[string]string + } + + clouds := []cloudMetadata{ + { + name: "digitalocean", + ipURL: "http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address", + idURL: "http://169.254.169.254/metadata/v1/id", + header: nil, + }, + { + name: "aws", + ipURL: "http://169.254.169.254/latest/meta-data/public-ipv4", + idURL: "http://169.254.169.254/latest/meta-data/instance-id", + header: nil, + }, + { + name: "gcp", + ipURL: "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip", + idURL: "http://metadata.google.internal/computeMetadata/v1/instance/id", + header: map[string]string{"Metadata-Flavor": "Google"}, + }, + { + name: "azure", + ipURL: "http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text", + idURL: "http://169.254.169.254/metadata/instance/compute/vmId?api-version=2021-02-01&format=text", + header: map[string]string{"Metadata": "true"}, + }, + } + + client := &http.Client{Timeout: 2 * time.Second} + + for _, cloud := range clouds { + req, err := http.NewRequest("GET", cloud.idURL, nil) + if err != nil { + continue + } + for k, v := range cloud.header { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + continue + } + resp.Body.Close() + + req, err = http.NewRequest("GET", cloud.ipURL, nil) + if err != nil { + continue + } + for k, v := range cloud.header { + req.Header.Set(k, v) + } + + resp, err = client.Do(req) + if err != nil { + continue + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + body, err := io.ReadAll(resp.Body) + if err == nil { + ip := strings.TrimSpace(string(body)) + if ip != "" && net.ParseIP(ip) != nil { + return ip, cloud.name + } + } + } + } + + ip := m.detectPublicIPFallback() + return ip, "" +} + +func (m *Manager) detectPublicIPFallback() string { + services := []string{ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + } + + client := &http.Client{Timeout: 5 * time.Second} + + for _, url := range services { + resp, err := client.Get(url) + if err != nil { + continue + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + body, err := io.ReadAll(resp.Body) + if err == nil { + ip := strings.TrimSpace(string(body)) + if net.ParseIP(ip) != nil { + return ip + } + } + } + } + + conn, err := net.Dial("udp", "8.8.8.8:80") + if err == nil { + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.String() + } + + return "" +} + +func (m *Manager) SetInstanceIP(ip string) error { + return m.db.SetInstanceIP(ip) +} + +func (m *Manager) InstallUI() error { + m.mu.Lock() + defer m.mu.Unlock() + + script := "/opt/flatrun/bin/install-ui.sh" + if _, err := os.Stat(script); os.IsNotExist(err) { + return fmt.Errorf("UI installer script not found at %s", script) + } + + cmd := exec.Command("/bin/bash", script) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("UI installation failed: %v\nOutput: %s", err, string(output)) + } + + return nil +} + +func generateSecret(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + fallback := make([]byte, length) + for i := range fallback { + fallback[i] = byte(os.Getpid()>>i) ^ byte(time.Now().UnixNano()>>i) + } + return hex.EncodeToString(fallback) + } + return hex.EncodeToString(bytes) +} diff --git a/internal/setup/state.go b/internal/setup/state.go new file mode 100644 index 0000000..b5c33b7 --- /dev/null +++ b/internal/setup/state.go @@ -0,0 +1,177 @@ +package setup + +import ( + "database/sql" + "os" + "path/filepath" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + conn *sql.DB + path string + mu sync.RWMutex +} + +type SetupState struct { + ID int64 + Initialized bool + InitializedAt time.Time + JWTSecret string + InstanceIP string + CloudProvider string + DeploymentMode string + UIOrigin string + Domain string + AutoSSL bool + CreatedAt time.Time +} + +func NewSetupDB(deploymentsPath string) (*DB, error) { + dbDir := filepath.Join(deploymentsPath, ".flatrun") + if err := os.MkdirAll(dbDir, 0755); err != nil { + return nil, err + } + + dbPath := filepath.Join(dbDir, "setup.db") + conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + + conn.SetMaxOpenConns(5) + conn.SetMaxIdleConns(2) + conn.SetConnMaxLifetime(time.Hour) + + db := &DB{conn: conn, path: dbPath} + if err := db.migrate(); err != nil { + conn.Close() + return nil, err + } + + return db, nil +} + +func (db *DB) Close() error { + db.mu.Lock() + defer db.mu.Unlock() + return db.conn.Close() +} + +func (db *DB) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS setup_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + initialized BOOLEAN DEFAULT FALSE, + initialized_at DATETIME, + jwt_secret TEXT, + instance_ip TEXT, + cloud_provider TEXT, + deployment_mode TEXT, + ui_origin TEXT, + domain TEXT, + auto_ssl BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + INSERT OR IGNORE INTO setup_state (id, initialized, created_at) VALUES (1, FALSE, CURRENT_TIMESTAMP); + ` + + _, err := db.conn.Exec(schema) + return err +} + +func (db *DB) GetState() (*SetupState, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + var state SetupState + var initializedAt sql.NullTime + var jwtSecret, instanceIP, cloudProvider, deploymentMode, uiOrigin, domain sql.NullString + var autoSSL sql.NullBool + + err := db.conn.QueryRow(` + SELECT id, initialized, initialized_at, jwt_secret, instance_ip, cloud_provider, + deployment_mode, ui_origin, domain, auto_ssl, created_at + FROM setup_state WHERE id = 1`).Scan( + &state.ID, &state.Initialized, &initializedAt, &jwtSecret, &instanceIP, + &cloudProvider, &deploymentMode, &uiOrigin, &domain, &autoSSL, &state.CreatedAt, + ) + if err != nil { + return nil, err + } + + if initializedAt.Valid { + state.InitializedAt = initializedAt.Time + } + state.JWTSecret = jwtSecret.String + state.InstanceIP = instanceIP.String + state.CloudProvider = cloudProvider.String + state.DeploymentMode = deploymentMode.String + state.UIOrigin = uiOrigin.String + state.Domain = domain.String + if autoSSL.Valid { + state.AutoSSL = autoSSL.Bool + } + + return &state, nil +} + +func (db *DB) SetDeploymentMode(mode string) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET deployment_mode = ? WHERE id = 1`, mode) + return err +} + +func (db *DB) SetJWTSecret(secret string) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET jwt_secret = ? WHERE id = 1`, secret) + return err +} + +func (db *DB) SetInstanceIP(ip string) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET instance_ip = ? WHERE id = 1`, ip) + return err +} + +func (db *DB) SetCloudProvider(provider string) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET cloud_provider = ? WHERE id = 1`, provider) + return err +} + +func (db *DB) SetUIOrigin(origin string) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET ui_origin = ? WHERE id = 1`, origin) + return err +} + +func (db *DB) SetDomain(domain string, autoSSL bool) error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET domain = ?, auto_ssl = ? WHERE id = 1`, domain, autoSSL) + return err +} + +func (db *DB) MarkInitialized() error { + db.mu.Lock() + defer db.mu.Unlock() + + _, err := db.conn.Exec(`UPDATE setup_state SET initialized = TRUE, initialized_at = ? WHERE id = 1`, time.Now()) + return err +} diff --git a/internal/setup/validation.go b/internal/setup/validation.go new file mode 100644 index 0000000..76d4d3f --- /dev/null +++ b/internal/setup/validation.go @@ -0,0 +1,309 @@ +package setup + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "syscall" + "time" + + "github.com/docker/docker/client" +) + +type SystemCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` + Required bool `json:"required"` +} + +const ( + StatusPass = "pass" + StatusFail = "fail" + StatusWarn = "warn" +) + +func (m *Manager) RunValidation() []SystemCheck { + checks := []SystemCheck{} + + checks = append(checks, m.checkDocker()) + checks = append(checks, m.checkDockerSocket()) + checks = append(checks, m.checkDeploymentsDir()) + checks = append(checks, m.checkDiskSpace()) + checks = append(checks, m.checkMemory()) + checks = append(checks, m.checkNetwork()) + + return checks +} + +func (m *Manager) checkDocker() SystemCheck { + check := SystemCheck{ + Name: "Docker Daemon", + Required: true, + } + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + check.Status = StatusFail + check.Message = fmt.Sprintf("Failed to create Docker client: %v", err) + return check + } + defer cli.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = cli.Ping(ctx) + if err != nil { + check.Status = StatusFail + check.Message = fmt.Sprintf("Docker daemon not responding: %v", err) + return check + } + + info, err := cli.Info(ctx) + if err != nil { + check.Status = StatusPass + check.Message = "Docker daemon is running" + return check + } + + check.Status = StatusPass + check.Message = fmt.Sprintf("Docker %s with %d containers", info.ServerVersion, info.Containers) + return check +} + +func (m *Manager) checkDockerSocket() SystemCheck { + check := SystemCheck{ + Name: "Docker Socket", + Required: true, + } + + socketPath := "/var/run/docker.sock" + if m.config != nil && m.config.DockerSocket != "" { + if len(m.config.DockerSocket) > 7 && m.config.DockerSocket[:7] == "unix://" { + socketPath = m.config.DockerSocket[7:] + } + } + + info, err := os.Stat(socketPath) + if err != nil { + if os.IsNotExist(err) { + check.Status = StatusFail + check.Message = fmt.Sprintf("Docker socket not found at %s", socketPath) + } else { + check.Status = StatusFail + check.Message = fmt.Sprintf("Cannot access Docker socket: %v", err) + } + return check + } + + if info.Mode()&os.ModeSocket == 0 { + check.Status = StatusFail + check.Message = fmt.Sprintf("%s is not a socket", socketPath) + return check + } + + file, err := os.OpenFile(socketPath, os.O_RDWR, 0) + if err != nil { + check.Status = StatusFail + check.Message = "No read/write permission on Docker socket" + return check + } + file.Close() + + check.Status = StatusPass + check.Message = "Docker socket accessible with proper permissions" + return check +} + +func (m *Manager) checkDeploymentsDir() SystemCheck { + check := SystemCheck{ + Name: "Deployments Directory", + Required: true, + } + + deploymentsPath := "/deployments" + if m.config != nil && m.config.DeploymentsPath != "" { + deploymentsPath = m.config.DeploymentsPath + } + + info, err := os.Stat(deploymentsPath) + if err != nil { + if os.IsNotExist(err) { + err := os.MkdirAll(deploymentsPath, 0755) + if err != nil { + check.Status = StatusFail + check.Message = fmt.Sprintf("Cannot create deployments directory: %v", err) + return check + } + check.Status = StatusPass + check.Message = fmt.Sprintf("Created deployments directory at %s", deploymentsPath) + return check + } + check.Status = StatusFail + check.Message = fmt.Sprintf("Cannot access deployments directory: %v", err) + return check + } + + if !info.IsDir() { + check.Status = StatusFail + check.Message = fmt.Sprintf("%s exists but is not a directory", deploymentsPath) + return check + } + + testFile := deploymentsPath + "/.write_test" + err = os.WriteFile(testFile, []byte("test"), 0644) + if err != nil { + check.Status = StatusFail + check.Message = "Deployments directory is not writable" + return check + } + os.Remove(testFile) + + check.Status = StatusPass + check.Message = fmt.Sprintf("Deployments directory ready at %s", deploymentsPath) + return check +} + +func (m *Manager) checkDiskSpace() SystemCheck { + check := SystemCheck{ + Name: "Disk Space", + Required: false, + } + + deploymentsPath := "/deployments" + if m.config != nil && m.config.DeploymentsPath != "" { + deploymentsPath = m.config.DeploymentsPath + } + + var stat syscall.Statfs_t + err := syscall.Statfs(deploymentsPath, &stat) + if err != nil { + check.Status = StatusWarn + check.Message = "Could not determine disk space" + return check + } + + availableGB := float64(stat.Bavail*uint64(stat.Bsize)) / (1024 * 1024 * 1024) + totalGB := float64(stat.Blocks*uint64(stat.Bsize)) / (1024 * 1024 * 1024) + usedPercent := (1 - float64(stat.Bavail)/float64(stat.Blocks)) * 100 + + if availableGB < 1 { + check.Status = StatusFail + check.Message = fmt.Sprintf("Critically low disk space: %.1f GB available", availableGB) + } else if availableGB < 5 { + check.Status = StatusWarn + check.Message = fmt.Sprintf("Low disk space: %.1f GB available (%.0f%% used of %.0f GB)", availableGB, usedPercent, totalGB) + } else { + check.Status = StatusPass + check.Message = fmt.Sprintf("%.1f GB available (%.0f%% used of %.0f GB)", availableGB, usedPercent, totalGB) + } + + return check +} + +func (m *Manager) checkMemory() SystemCheck { + check := SystemCheck{ + Name: "System Memory", + Required: false, + } + + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + check.Status = StatusWarn + check.Message = "Could not determine memory information" + return check + } + + var totalKB, availableKB uint64 + lines := string(data) + for _, line := range splitLines(lines) { + if len(line) > 9 && line[:9] == "MemTotal:" { + fmt.Sscanf(line, "MemTotal: %d kB", &totalKB) + } + if len(line) > 13 && line[:13] == "MemAvailable:" { + fmt.Sscanf(line, "MemAvailable: %d kB", &availableKB) + } + } + + if totalKB == 0 { + check.Status = StatusWarn + check.Message = "Could not parse memory information" + return check + } + + totalGB := float64(totalKB) / (1024 * 1024) + availableGB := float64(availableKB) / (1024 * 1024) + usedPercent := (1 - float64(availableKB)/float64(totalKB)) * 100 + + if totalGB < 0.5 { + check.Status = StatusFail + check.Message = fmt.Sprintf("Insufficient memory: %.1f GB total", totalGB) + } else if availableGB < 0.25 { + check.Status = StatusWarn + check.Message = fmt.Sprintf("Low available memory: %.2f GB free (%.0f%% used of %.1f GB)", availableGB, usedPercent, totalGB) + } else { + check.Status = StatusPass + check.Message = fmt.Sprintf("%.2f GB available (%.0f%% used of %.1f GB)", availableGB, usedPercent, totalGB) + } + + return check +} + +func (m *Manager) checkNetwork() SystemCheck { + check := SystemCheck{ + Name: "Network Connectivity", + Required: false, + } + + conn, err := net.DialTimeout("tcp", "8.8.8.8:53", 5*time.Second) + if err != nil { + check.Status = StatusWarn + check.Message = "Cannot reach external network" + return check + } + conn.Close() + + _, err = net.LookupHost("registry.hub.docker.com") + if err != nil { + check.Status = StatusWarn + check.Message = "DNS resolution not working" + return check + } + + check.Status = StatusPass + check.Message = "Network connectivity OK" + return check +} + +func (m *Manager) CheckPortAvailable(port int) bool { + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return false + } + ln.Close() + return true +} + +func (m *Manager) CheckCommandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +}