mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4471
-94235
No files matched your search
+14
-183
@@ -1,6 +1,4 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
||||
import { Pool } from 'pg';
|
||||
import { PostgresDialect } from 'kysely';
|
||||
import { getMigrations } from 'better-auth/db/migration';
|
||||
@@ -50,41 +48,22 @@ const database = new PostgresDialect({
|
||||
|
||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID';
|
||||
const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET';
|
||||
const gitlabAuthorizeUrl =
|
||||
env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize';
|
||||
const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token';
|
||||
const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user';
|
||||
|
||||
const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID';
|
||||
const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET';
|
||||
const giteaAuthorizeUrl =
|
||||
env.GITEA_OAUTH_AUTHORIZE_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/authorize';
|
||||
const giteaTokenUrl =
|
||||
env.GITEA_OAUTH_TOKEN_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/access_token';
|
||||
const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user';
|
||||
|
||||
const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID';
|
||||
const bitbucketClientSecret =
|
||||
env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET';
|
||||
const bitbucketAuthorizeUrl =
|
||||
env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize';
|
||||
const bitbucketTokenUrl =
|
||||
env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token';
|
||||
const bitbucketUserUrl =
|
||||
env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user';
|
||||
const bitbucketEmailsUrl =
|
||||
env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails';
|
||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const socialProviders = {};
|
||||
if (githubClientId && githubClientSecret) {
|
||||
socialProviders.github = {
|
||||
clientId: githubClientId,
|
||||
clientSecret: githubClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
if (googleClientId && googleClientSecret) {
|
||||
socialProviders.google = {
|
||||
clientId: googleClientId,
|
||||
clientSecret: googleClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +76,10 @@ const trustedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8082',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
]
|
||||
.map((origin) => (origin || '').trim())
|
||||
.filter(Boolean);
|
||||
@@ -130,158 +113,6 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url, token, metadata }) => {
|
||||
const payload = {
|
||||
email,
|
||||
url,
|
||||
token,
|
||||
metadata,
|
||||
};
|
||||
console.log('[better-auth] magic-link', JSON.stringify(payload));
|
||||
},
|
||||
disableSignUp: false,
|
||||
}),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'gitlab',
|
||||
clientId: gitlabClientId,
|
||||
clientSecret: gitlabClientSecret,
|
||||
authorizationUrl: gitlabAuthorizeUrl,
|
||||
tokenUrl: gitlabTokenUrl,
|
||||
userInfoUrl: gitlabUserUrl,
|
||||
scopes: ['read_user', 'read_api'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(gitlabUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.username,
|
||||
email: profile.email || null,
|
||||
name: profile.name || profile.username || 'GitLab User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'gitea',
|
||||
clientId: giteaClientId,
|
||||
clientSecret: giteaClientSecret,
|
||||
authorizationUrl: giteaAuthorizeUrl,
|
||||
tokenUrl: giteaTokenUrl,
|
||||
userInfoUrl: giteaUserUrl,
|
||||
scopes: ['read:user', 'read:repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(giteaUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `token ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.login)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.login,
|
||||
email: profile.email || null,
|
||||
name: profile.full_name || profile.login || 'Gitea User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'bitbucket',
|
||||
clientId: bitbucketClientId,
|
||||
clientSecret: bitbucketClientSecret,
|
||||
authorizationUrl: bitbucketAuthorizeUrl,
|
||||
tokenUrl: bitbucketTokenUrl,
|
||||
userInfoUrl: bitbucketUserUrl,
|
||||
scopes: ['account', 'email', 'repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userResponse = await fetch(bitbucketUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userResponse.json();
|
||||
if (!user || (!user.account_id && !user.username && !user.nickname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let primaryEmail = null;
|
||||
const emailsResponse = await fetch(bitbucketEmailsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
if (emailsResponse.ok) {
|
||||
const emailsPayload = await emailsResponse.json();
|
||||
const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : [];
|
||||
const primary = values.find((entry) => entry?.is_primary) || values[0];
|
||||
if (primary && typeof primary.email === 'string') {
|
||||
primaryEmail = primary.email;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.account_id || user.username || user.nickname,
|
||||
email: primaryEmail,
|
||||
name: user.display_name || user.nickname || 'Bitbucket User',
|
||||
image: user?.links?.avatar?.href || null,
|
||||
emailVerified: Boolean(primaryEmail),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function runAuthMigrations() {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type agentConfig struct {
|
||||
APIURL string
|
||||
Token string
|
||||
Name string
|
||||
Hostname string
|
||||
IP string
|
||||
Port int
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
type capabilities struct {
|
||||
ContainerRuntimes []string `json:"container_runtimes"`
|
||||
SupportedArchitectures []string `json:"supported_architectures"`
|
||||
MaxContainers int `json:"max_containers"`
|
||||
StorageDriver string `json:"storage_driver"`
|
||||
NetworkPlugins []string `json:"network_plugins"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
type nodeResources struct {
|
||||
CPU cpuResources `json:"cpu"`
|
||||
Memory memoryResources `json:"memory"`
|
||||
Storage storageResources `json:"storage"`
|
||||
Network networkResources `json:"network"`
|
||||
}
|
||||
|
||||
type cpuResources struct {
|
||||
Cores int `json:"cores"`
|
||||
Allocation float64 `json:"allocation"`
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
type memoryResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type storageResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type networkResources struct {
|
||||
Interfaces []networkInterface `json:"interfaces"`
|
||||
Bandwidth bandwidthInfo `json:"bandwidth"`
|
||||
}
|
||||
|
||||
type networkInterface struct {
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
Speed int `json:"speed"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type bandwidthInfo struct {
|
||||
Inbound int `json:"inbound"`
|
||||
Outbound int `json:"outbound"`
|
||||
}
|
||||
|
||||
type systemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
Load15M float64 `json:"load_15m"`
|
||||
}
|
||||
|
||||
type command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type commandList struct {
|
||||
Commands []command `json:"commands"`
|
||||
}
|
||||
|
||||
type registerResponse struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
agentID, err := register(context.Background(), client, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL)
|
||||
ticker := time.NewTicker(cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("heartbeat failed: %v", err)
|
||||
}
|
||||
if err := processCommands(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("command poll failed: %v", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (agentConfig, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/")
|
||||
apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api")
|
||||
token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN"))
|
||||
}
|
||||
if token == "" {
|
||||
return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN")
|
||||
}
|
||||
|
||||
intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15"))
|
||||
if intervalSeconds < 5 {
|
||||
intervalSeconds = 5
|
||||
}
|
||||
port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090"))
|
||||
|
||||
return agentConfig{
|
||||
APIURL: apiURL,
|
||||
Token: token,
|
||||
Name: firstEnv("CONTAINR_AGENT_NAME", hostname),
|
||||
Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname),
|
||||
IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()),
|
||||
Port: port,
|
||||
Interval: time.Duration(intervalSeconds) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"name": cfg.Name,
|
||||
"hostname": cfg.Hostname,
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.AgentID == "" {
|
||||
return "", errors.New("register response missing agent_id")
|
||||
}
|
||||
return response.AgentID, nil
|
||||
}
|
||||
|
||||
func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
resources := collectResources(cfg.IP)
|
||||
payload := map[string]interface{}{
|
||||
"node_agent_id": agentID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"status": agentStatus(),
|
||||
"resources": resources,
|
||||
"container_count": dockerContainerCount(),
|
||||
"system_load": readSystemLoad(),
|
||||
"uptime": readSystemUptime(),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
||||
}
|
||||
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||
}
|
||||
|
||||
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("command poll status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var list commandList
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range list.Commands {
|
||||
result, runErr := executeCommand(item)
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if runErr != nil {
|
||||
status = "failed"
|
||||
errText = runErr.Error()
|
||||
}
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
"result": result,
|
||||
"error": errText,
|
||||
}
|
||||
path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID)
|
||||
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeCommand(item command) (string, error) {
|
||||
switch item.Type {
|
||||
case "create_container":
|
||||
return createContainer(item.Payload)
|
||||
case "start_container":
|
||||
return runDocker("start", commandTarget(item.Payload))
|
||||
case "stop_container":
|
||||
return runDocker("stop", commandTarget(item.Payload))
|
||||
case "restart_container":
|
||||
return runDocker("restart", commandTarget(item.Payload))
|
||||
case "remove_container":
|
||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func createContainer(payload map[string]interface{}) (string, error) {
|
||||
container, ok := payload["container"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errors.New("missing container payload")
|
||||
}
|
||||
|
||||
name := stringValue(container["name"])
|
||||
image := stringValue(container["image"])
|
||||
if name == "" || image == "" {
|
||||
return "", errors.New("container name and image are required")
|
||||
}
|
||||
|
||||
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
|
||||
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||
for key, value := range env {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
}
|
||||
if ports, ok := container["ports"].([]interface{}); ok {
|
||||
for _, rawPort := range ports {
|
||||
port, ok := rawPort.(map[string]interface{})
|
||||
if !ok || !boolValue(port["published"]) {
|
||||
continue
|
||||
}
|
||||
hostPort := intValue(port["host_port"])
|
||||
containerPort := intValue(port["container_port"])
|
||||
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
||||
if hostPort > 0 && containerPort > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
|
||||
}
|
||||
}
|
||||
}
|
||||
if volumes, ok := container["volumes"].([]interface{}); ok {
|
||||
for _, rawVolume := range volumes {
|
||||
volume, ok := rawVolume.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := stringValue(volume["source"])
|
||||
target := stringValue(volume["target"])
|
||||
if source != "" && target != "" {
|
||||
mount := source + ":" + target
|
||||
if boolValue(volume["read_only"]) {
|
||||
mount += ":ro"
|
||||
}
|
||||
args = append(args, "-v", mount)
|
||||
}
|
||||
}
|
||||
}
|
||||
args = append(args, image)
|
||||
return runDocker(args...)
|
||||
}
|
||||
|
||||
func commandTarget(payload map[string]interface{}) string {
|
||||
for _, key := range []string{"container_name", "docker_name", "name", "container_id"} {
|
||||
if value := stringValue(payload[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func detectCapabilities() capabilities {
|
||||
runtimes := []string{}
|
||||
if dockerAvailable() {
|
||||
runtimes = append(runtimes, "docker")
|
||||
}
|
||||
return capabilities{
|
||||
ContainerRuntimes: runtimes,
|
||||
SupportedArchitectures: []string{runtime.GOARCH},
|
||||
MaxContainers: 100,
|
||||
StorageDriver: dockerStorageDriver(),
|
||||
NetworkPlugins: []string{"bridge"},
|
||||
Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"},
|
||||
}
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
return nodeResources{
|
||||
CPU: cpuResources{
|
||||
Cores: runtime.NumCPU(),
|
||||
Allocation: 0,
|
||||
Usage: readCPULoadPercent(),
|
||||
},
|
||||
Memory: mem,
|
||||
Storage: storage,
|
||||
Network: networkResources{
|
||||
Interfaces: []networkInterface{{
|
||||
Name: "primary",
|
||||
IPAddress: ip,
|
||||
Speed: 0,
|
||||
Status: "up",
|
||||
}},
|
||||
Bandwidth: bandwidthInfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readMemory() memoryResources {
|
||||
values := map[string]int{}
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return memoryResources{}
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(fields[1])
|
||||
if err == nil {
|
||||
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return memoryResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readStorage(path string) storageResources {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return storageResources{}
|
||||
}
|
||||
total := int(stat.Blocks * uint64(stat.Bsize))
|
||||
available := int(stat.Bavail * uint64(stat.Bsize))
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return storageResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readSystemLoad() systemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return systemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return systemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return int64(uptime)
|
||||
}
|
||||
|
||||
func readCPULoadPercent() float64 {
|
||||
load := readSystemLoad()
|
||||
cores := runtime.NumCPU()
|
||||
if cores == 0 {
|
||||
return 0
|
||||
}
|
||||
percent := load.Load1M / float64(cores) * 100
|
||||
if percent < 0 {
|
||||
return 0
|
||||
}
|
||||
if percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func runDocker(args ...string) (string, error) {
|
||||
if len(args) == 0 || args[len(args)-1] == "" {
|
||||
return "", errors.New("missing docker target")
|
||||
}
|
||||
cmd := exec.Command("docker", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
func dockerAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func dockerStorageDriver() string {
|
||||
out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func dockerContainerCount() int {
|
||||
out, err := exec.Command("docker", "ps", "-q").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func agentStatus() string {
|
||||
if dockerAvailable() {
|
||||
return "online"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
|
||||
func detectOutboundIP() string {
|
||||
conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second)
|
||||
if err != nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
defer conn.Close()
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP.String()
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func firstEnv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstString(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value interface{}) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case json.Number:
|
||||
out, _ := typed.Int64()
|
||||
return int(out)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(value interface{}) bool {
|
||||
typed, _ := value.(bool)
|
||||
return typed
|
||||
}
|
||||
@@ -48,8 +48,8 @@ func main() {
|
||||
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
|
||||
}
|
||||
|
||||
// Seed demo data in development (or when explicitly requested).
|
||||
if cfg.IsDevelopment() || cfg.SeedDataOnStart {
|
||||
// Seed demo data only when explicitly requested.
|
||||
if cfg.SeedDataOnStart {
|
||||
if err := db.SeedData(); err != nil {
|
||||
log.Printf("Warning: Failed to seed data: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ type AgentCommand struct {
|
||||
// AgentHeartbeat represents a heartbeat message from an agent
|
||||
type AgentHeartbeat struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
@@ -193,6 +194,10 @@ type AgentHeartbeatRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AgentHeartbeatRecord) TableName() string {
|
||||
return "agent_heartbeats"
|
||||
}
|
||||
|
||||
type SystemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
@@ -216,7 +221,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -224,7 +229,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidAgentAuthToken(req.AuthToken) {
|
||||
authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
@@ -246,7 +252,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": existingAgent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "updated",
|
||||
})
|
||||
return
|
||||
@@ -308,7 +314,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"agent_id": agent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "registered",
|
||||
})
|
||||
}
|
||||
@@ -388,6 +394,11 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
if heartbeat.Timestamp.IsZero() {
|
||||
heartbeat.Timestamp = time.Now()
|
||||
}
|
||||
@@ -436,6 +447,118 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents.
|
||||
func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
var commands []AgentCommand
|
||||
if err := h.db.
|
||||
Where("node_agent_id = ? AND status = ?", agentID, "pending").
|
||||
Order("created_at ASC").
|
||||
Limit(25).
|
||||
Find(&commands).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
// CompleteCommand lets a node agent report command completion or failure.
|
||||
func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
commandID := c.Param("commandId")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status != "completed" && req.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
command.Status = req.Status
|
||||
command.UpdatedAt = now
|
||||
command.CompletedAt = &now
|
||||
if req.Result != "" {
|
||||
command.Result = &req.Result
|
||||
}
|
||||
if req.Error != "" {
|
||||
command.Error = &req.Error
|
||||
}
|
||||
|
||||
if err := h.db.Save(&command).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
||||
return
|
||||
}
|
||||
|
||||
if command.ContainerID != nil {
|
||||
h.updateContainerStatusAfterCommand(command)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if command.Status == "failed" {
|
||||
errorMessage := ""
|
||||
if command.Error != nil {
|
||||
errorMessage = *command.Error
|
||||
}
|
||||
container.Status.State = "error"
|
||||
container.Status.Error = &errorMessage
|
||||
_ = h.db.Save(&container).Error
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch command.Type {
|
||||
case "create_container", "start_container", "restart_container":
|
||||
container.Status.State = "running"
|
||||
container.Status.Health = "unknown"
|
||||
container.Status.StartedAt = &now
|
||||
container.Status.Error = nil
|
||||
case "stop_container":
|
||||
container.Status.State = "stopped"
|
||||
container.Status.FinishedAt = &now
|
||||
case "remove_container":
|
||||
container.Status.State = "removed"
|
||||
container.Status.FinishedAt = &now
|
||||
}
|
||||
container.UpdatedAt = now
|
||||
_ = h.db.Save(&container).Error
|
||||
}
|
||||
|
||||
// GetAgentContainers returns containers running on a specific agent
|
||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
@@ -634,7 +757,9 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
"container_id": containerID,
|
||||
"container_id": containerID,
|
||||
"container_name": container.Name,
|
||||
"docker_name": container.Name,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -757,6 +882,19 @@ func isValidAgentAuthToken(token string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func agentAuthTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" {
|
||||
const bearerPrefix = "Bearer "
|
||||
if strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Query("auth_token"))
|
||||
}
|
||||
|
||||
func configuredAgentAuthTokens() []string {
|
||||
candidateCSV := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKENS"))
|
||||
if candidateCSV != "" {
|
||||
@@ -806,16 +944,25 @@ func maxInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// SetupRoutes registers the agent routes
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
// SetupPublicRoutes registers token-authenticated agent ingestion routes.
|
||||
func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.POST("/register", h.RegisterAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers authenticated dashboard/control agent routes.
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.GET("", h.GetAgents)
|
||||
agents.GET("/:id", h.GetAgent)
|
||||
agents.PUT("/:id", h.UpdateAgent)
|
||||
agents.DELETE("/:id", h.DeleteAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
|
||||
agents.GET("/:id/containers", h.GetAgentContainers)
|
||||
agents.POST("/:id/containers", h.CreateContainer)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/database"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -18,10 +26,16 @@ type LoginRequest struct {
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type ManualUserCreateRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User interface{} `json:"user"`
|
||||
@@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) {
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err = createLocalUserFromBetterAuth(db, authUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
} else {
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
@@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleAuthBootstrap(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := "login"
|
||||
if count == 0 {
|
||||
mode = "register"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
})
|
||||
}
|
||||
|
||||
type betterAuthLoginUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type betterAuthLoginResponse struct {
|
||||
User betterAuthLoginUser `json:"user"`
|
||||
}
|
||||
|
||||
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
||||
endpoint, err := betterAuthSignInURL()
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback disabled: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"email": strings.TrimSpace(email),
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
var payload betterAuthLoginResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
log.Printf("Better Auth fallback response decode failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
||||
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
||||
if payload.User.Name == "" {
|
||||
payload.User.Name = "Containr User"
|
||||
}
|
||||
|
||||
if payload.User.Email == "" {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
return payload.User, true
|
||||
}
|
||||
|
||||
func betterAuthSignInURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
||||
}
|
||||
|
||||
func betterAuthSignUpURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
||||
}
|
||||
|
||||
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
||||
if email == "" {
|
||||
return User{}, errors.New("email is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(authUser.Name)
|
||||
if name == "" {
|
||||
name = "Containr User"
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func handleRegister(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -91,9 +264,19 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
// Check if user already exists
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
@@ -137,6 +320,121 @@ func handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, errBetterAuthUserExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"user": user})
|
||||
}
|
||||
|
||||
var errBetterAuthUserExists = errors.New("User already exists")
|
||||
|
||||
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
||||
endpoint, err := betterAuthSignUpURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||
}
|
||||
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||
message := "Failed to create auth user"
|
||||
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
||||
message = rawMessage
|
||||
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
||||
message = rawError
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
||||
return errBetterAuthUserExists
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func countLocalUsers(db *database.DB) (int, error) {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func handleGetProfile(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) {
|
||||
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: auth proxy disabled: %v", err)
|
||||
@@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
}
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
if shouldBlockPublicBetterAuthSignup(c, cfg, db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
router.Any("/api/auth/*proxyPath", handler)
|
||||
}
|
||||
|
||||
func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool {
|
||||
if db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.EqualFold(c.Request.Method, http.MethodPost) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") {
|
||||
return false
|
||||
}
|
||||
|
||||
internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken)
|
||||
if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken {
|
||||
return false
|
||||
}
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
log.Printf("Failed to inspect signup bootstrap state: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(rawTarget)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// handleDeleteGitProvider removes a provider connection. Repositories and
|
||||
// webhooks are removed by ON DELETE CASCADE.
|
||||
func handleDeleteGitProvider(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctx.db.Exec(
|
||||
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||
providerID, ctx.userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
|
||||
}
|
||||
|
||||
// handleGetGitRepositoryBranches lists branches for a repository through the
|
||||
// provider's stored credentials.
|
||||
func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
owner := strings.TrimSpace(c.Param("owner"))
|
||||
repo := strings.TrimSpace(c.Param("repo"))
|
||||
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerAPIURL, providerAccessToken string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||
if err != nil {
|
||||
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||
}
|
||||
|
||||
type remoteBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
||||
case "github", "github_app":
|
||||
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
|
||||
case "gitea":
|
||||
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
|
||||
default:
|
||||
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(accessToken)
|
||||
if strings.EqualFold(providerName, "github_app") {
|
||||
installationID, err := strconv.ParseInt(token, 10, 64)
|
||||
if err != nil || installationID <= 0 {
|
||||
return nil, fmt.Errorf("github_app installation id is invalid")
|
||||
}
|
||||
token, err = getGitHubAppInstallationToken(baseURL, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("github access token is missing")
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0)
|
||||
for page := 1; page <= 5; page++ {
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github+json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
|
||||
response.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
name := asGitString(row["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
|
||||
if len(rows) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("gitea api_url is required")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(accessToken); token != "" {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if name := asGitString(row["name"]); name != "" {
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.")
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
if isDockerNotFoundError(err) {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
}
|
||||
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func respondEmptyServiceLogs(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{},
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ServiceMetricsResponse is the aggregated live telemetry for a service,
|
||||
// sourced from Docker container stats for containers named
|
||||
// containr-<serviceID>-<deploymentID>-<index>.
|
||||
type ServiceMetricsResponse struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Status string `json:"status"` // ok, no_containers, docker_unavailable
|
||||
Instances []ServiceInstanceMetrics `json:"instances"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// ServiceInstanceMetrics holds live stats for one running container.
|
||||
type ServiceInstanceMetrics struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// handleGetServiceMetrics returns real resource usage for the service's
|
||||
// Docker containers. Empty result when the service has never deployed.
|
||||
func handleGetServiceMetrics(c *gin.Context) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbValue.(*database.DB)
|
||||
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID, &status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
response := ServiceMetricsResponse{
|
||||
ServiceID: serviceID.String(),
|
||||
Status: "ok",
|
||||
Instances: []ServiceInstanceMetrics{},
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
dockerValue, exists := c.Get("docker_client")
|
||||
if !exists || dockerValue == nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
dockerClient := dockerValue.(*docker.Client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
containers, err := dockerClient.ListContainers(ctx, false)
|
||||
if err != nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
|
||||
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
|
||||
matched := 0
|
||||
for _, ctr := range containers {
|
||||
name := ""
|
||||
for _, n := range ctr.Names {
|
||||
name = strings.TrimPrefix(n, "/")
|
||||
if strings.HasPrefix(name, namePrefix) {
|
||||
break
|
||||
}
|
||||
name = ""
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
|
||||
instance := ServiceInstanceMetrics{
|
||||
ContainerID: ctr.ID,
|
||||
Name: name,
|
||||
State: ctr.State,
|
||||
}
|
||||
|
||||
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
|
||||
if info.State != nil && info.State.StartedAt != "" {
|
||||
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
|
||||
instance.StartedAt = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctr.State == "running" {
|
||||
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
|
||||
instance.CPUPercent = stats.cpuPercent
|
||||
instance.MemoryUsageBytes = stats.memoryUsage
|
||||
instance.MemoryLimitBytes = stats.memoryLimit
|
||||
instance.NetworkRxBytes = stats.networkRx
|
||||
instance.NetworkTxBytes = stats.networkTx
|
||||
}
|
||||
}
|
||||
|
||||
response.Instances = append(response.Instances, instance)
|
||||
response.CPUPercent += instance.CPUPercent
|
||||
response.MemoryUsageBytes += instance.MemoryUsageBytes
|
||||
response.MemoryLimitBytes += instance.MemoryLimitBytes
|
||||
response.NetworkRxBytes += instance.NetworkRxBytes
|
||||
response.NetworkTxBytes += instance.NetworkTxBytes
|
||||
}
|
||||
|
||||
if matched == 0 {
|
||||
response.Status = "no_containers"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
}
|
||||
|
||||
type containerStatsSnapshot struct {
|
||||
cpuPercent float64
|
||||
memoryUsage uint64
|
||||
memoryLimit uint64
|
||||
networkRx uint64
|
||||
networkTx uint64
|
||||
}
|
||||
|
||||
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
|
||||
reader, err := client.GetContainerStats(ctx, containerID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Body.Close()
|
||||
|
||||
var stats containertypes.StatsResponse
|
||||
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := &containerStatsSnapshot{
|
||||
memoryUsage: stats.MemoryStats.Usage,
|
||||
memoryLimit: stats.MemoryStats.Limit,
|
||||
}
|
||||
|
||||
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
||||
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
||||
if onlineCPUs == 0 {
|
||||
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
|
||||
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
|
||||
}
|
||||
|
||||
for _, net := range stats.Networks {
|
||||
snapshot.networkRx += net.RxBytes
|
||||
snapshot.networkTx += net.TxBytes
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg)
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
// Initialize Docker client (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
@@ -158,11 +158,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
router.HEAD("/ready", healthHandler)
|
||||
|
||||
// API v1 routes
|
||||
publicAgents := router.Group("/api")
|
||||
agentHandler.SetupPublicRoutes(publicAgents)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
public := v1.Group("/")
|
||||
{
|
||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// User routes
|
||||
protected.GET("/user/profile", handleGetProfile)
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
@@ -192,6 +197,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
@@ -212,7 +218,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
protected.GET("/git/providers", handleGetGitProviders)
|
||||
protected.POST("/git/providers", handleCreateGitProvider)
|
||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||
@@ -226,6 +234,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
|
||||
// System routes
|
||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
protected.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// Scaling routes
|
||||
scalingHandler.RegisterRoutes(protected)
|
||||
haAPIManager.RegisterRoutes(protected)
|
||||
@@ -241,9 +254,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
|
||||
// Node Agent routes
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.Auth(cfg.JWTSecret))
|
||||
agentHandler.SetupRoutes(api)
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
|
||||
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
|
||||
|
||||
// Get services for the project
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, name, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
|
||||
`SELECT id, project_id, name,
|
||||
COALESCE(type, service_type, ''),
|
||||
COALESCE(status, ''),
|
||||
COALESCE(image, image_name, ''),
|
||||
COALESCE(command, start_command, ''),
|
||||
COALESCE(environment, ''),
|
||||
COALESCE(git_repo, source_url, ''),
|
||||
COALESCE(git_branch, ''),
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
sourceType := inferServiceSourceType(service)
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, type, status, image, command, environment,
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
|
||||
service.Image, service.Command, service.Environment, service.GitRepo,
|
||||
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
var environmentID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||
projectID,
|
||||
environment,
|
||||
).Scan(&environmentID)
|
||||
return environmentID, err
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
if service.GitRepo != "" {
|
||||
return "github"
|
||||
}
|
||||
if service.Image != "" {
|
||||
return "image"
|
||||
}
|
||||
return "dockerfile"
|
||||
}
|
||||
|
||||
// handleGetService retrieves a specific service
|
||||
func handleGetService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
|
||||
// Get service with project ownership check
|
||||
var service Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
// Check if service exists and user has access
|
||||
var existingService Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upgradeStatusResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
Registry string `json:"registry"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Installed bool `json:"installed"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type hostMonitoringResponse struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPU hostCPU `json:"cpu"`
|
||||
Memory hostMemory `json:"memory"`
|
||||
Storage hostStorage `json:"storage"`
|
||||
Load SystemLoad `json:"load"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Docker map[string]any `json:"docker,omitempty"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
type hostCPU struct {
|
||||
Cores int `json:"cores"`
|
||||
}
|
||||
|
||||
type hostMemory struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type hostStorage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
func handleGetUpgradeStatus(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, _ := dockerClient.(*docker.Client)
|
||||
|
||||
response := upgradeStatusResponse{
|
||||
ImageRef: imageRef,
|
||||
Registry: registryHost(imageRef),
|
||||
DockerAvailable: client != nil,
|
||||
AuthConfigured: upgradeRegistryAuthConfigured(),
|
||||
Message: "Upgrade image auto-detected",
|
||||
}
|
||||
|
||||
if imageRef == "" {
|
||||
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
response.Message = "Docker client unavailable"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := client.GetImageInfo(ctx, imageRef)
|
||||
if err != nil {
|
||||
response.Message = "Image not installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
response.Installed = true
|
||||
response.Digest = info.Digest
|
||||
response.Size = info.Size
|
||||
response.Message = "Image installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handlePullUpgradeImage(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
|
||||
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
||||
response := gin.H{
|
||||
"image_ref": imageRef,
|
||||
"registry": registryHost(imageRef),
|
||||
"message": "Image pull completed",
|
||||
}
|
||||
if infoErr == nil {
|
||||
response["digest"] = info.Digest
|
||||
response["size"] = info.Size
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleGetHostMonitoring(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
memory := readHostMemory()
|
||||
storage := readHostStorage("/")
|
||||
load := readSystemLoad()
|
||||
uptime := readSystemUptime()
|
||||
|
||||
response := hostMonitoringResponse{
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
CPU: hostCPU{Cores: runtime.NumCPU()},
|
||||
Memory: memory,
|
||||
Storage: storage,
|
||||
Load: load,
|
||||
UptimeSeconds: uptime,
|
||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if dockerClient, ok := c.Get("docker_client"); ok {
|
||||
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if info, err := client.GetSystemInfo(ctx); err == nil {
|
||||
response.DockerAvailable = true
|
||||
response.Docker = map[string]any{
|
||||
"containers": info.Containers,
|
||||
"images": info.Images,
|
||||
"driver": info.Driver,
|
||||
"server": info.ServerVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func configuredUpgradeImageRef() string {
|
||||
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func registryHost(imageRef string) string {
|
||||
parts := strings.Split(imageRef, "/")
|
||||
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
return parts[0]
|
||||
}
|
||||
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
||||
return "ghcr.io"
|
||||
}
|
||||
return "docker.io"
|
||||
}
|
||||
|
||||
func upgradeRegistryAuthConfigured() bool {
|
||||
auth := upgradeRegistryAuth()
|
||||
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
||||
}
|
||||
|
||||
func upgradeRegistryAuth() registry.AuthConfig {
|
||||
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
||||
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func firstConfigured(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readHostMemory() hostMemory {
|
||||
values := map[string]uint64{}
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return hostMemory{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = value * 1024
|
||||
}
|
||||
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := uint64(0)
|
||||
if total > available {
|
||||
used = total - available
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostMemory{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readHostStorage(path string) hostStorage {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return hostStorage{Path: path}
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
available := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - available
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostStorage{
|
||||
Path: path,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readSystemLoad() SystemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return SystemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return SystemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(uptime)
|
||||
}
|
||||
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
||||
serviceCommand := strings.TrimSpace(config.StartCommand)
|
||||
buildCommand := strings.TrimSpace(config.BuildCommand)
|
||||
cpu, memory := defaultTemplateResources(serviceType)
|
||||
serviceEnvironment := "production"
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
txQueries := queries.WithTx(tx)
|
||||
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: true},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: true},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
EnvironmentID: environmentID,
|
||||
ServiceType: serviceType,
|
||||
SourceType: "template",
|
||||
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
|
||||
@@ -146,6 +146,9 @@ func Load() *Config {
|
||||
cfg.CORSMethods = getenvSlice("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
|
||||
cfg.CORSHeaders = getenvSlice("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization"})
|
||||
cfg.CORSCredentials = getenvBool("CORS_CREDENTIALS", true)
|
||||
if cfg.IsDevelopment() {
|
||||
cfg.CORSOrigins = expandDevelopmentCORSOrigins(cfg.CORSOrigins)
|
||||
}
|
||||
|
||||
cfg.APIKeyHeader = getenv("API_KEY_HEADER", "X-API-Key")
|
||||
cfg.ServiceTokenHeader = getenv("SERVICE_TOKEN_HEADER", "X-Service-Token")
|
||||
@@ -331,6 +334,57 @@ func getenvSliceWithAliases(keys []string, defaultValue []string) []string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func expandDevelopmentCORSOrigins(origins []string) []string {
|
||||
hasLocalOrigin := false
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
expanded := make([]string, 0, len(origins)+12)
|
||||
|
||||
for _, origin := range origins {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
expanded = append(expanded, trimmed)
|
||||
|
||||
if strings.HasPrefix(trimmed, "http://localhost") ||
|
||||
strings.HasPrefix(trimmed, "http://127.0.0.1") ||
|
||||
strings.HasPrefix(trimmed, "http://0.0.0.0") {
|
||||
hasLocalOrigin = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLocalOrigin {
|
||||
return expanded
|
||||
}
|
||||
|
||||
for _, origin := range []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:5175",
|
||||
"http://0.0.0.0:3000",
|
||||
"http://0.0.0.0:5173",
|
||||
"http://0.0.0.0:5174",
|
||||
"http://0.0.0.0:5175",
|
||||
} {
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
expanded = append(expanded, origin)
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// encodeDatabasePassword URL-encodes the password in a database connection string
|
||||
func encodeDatabasePassword(databaseURL string) string {
|
||||
// Find the password part between :@ and encode it
|
||||
|
||||
@@ -12,9 +12,14 @@ func TestLoadUsesCORSAllowedOriginsAlias(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://app.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"http://localhost:3000", "https://app.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
if !containsAll(cfg.CORSOrigins, []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://app.example.com",
|
||||
}) {
|
||||
t.Fatalf("unexpected CORS origins: got %v", cfg.CORSOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,3 +185,27 @@ func TestLoadUsesNewStartupDefaults(t *testing.T) {
|
||||
t.Fatalf("expected positive MAX_REQUEST_BODY_BYTES, got %d", cfg.MaxRequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDoesNotExpandNonLocalDevelopmentCORSOrigins(t *testing.T) {
|
||||
t.Setenv("ENVIRONMENT", "development")
|
||||
t.Setenv("CORS_ORIGINS", "https://preferred.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"https://preferred.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(haystack []string, needles []string) bool {
|
||||
seen := make(map[string]struct{}, len(haystack))
|
||||
for _, item := range haystack {
|
||||
seen[item] = struct{}{}
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if _, ok := seen[needle]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -31,23 +31,70 @@ func (q *Queries) CountServicesByProjectAndName(ctx context.Context, arg CountSe
|
||||
}
|
||||
|
||||
const createServiceFromTemplate = `-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
)
|
||||
`
|
||||
|
||||
type CreateServiceFromTemplateParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error {
|
||||
@@ -55,6 +102,13 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.EnvironmentID,
|
||||
arg.ServiceType,
|
||||
arg.SourceType,
|
||||
arg.SourceUrl,
|
||||
arg.ImageName,
|
||||
arg.BuildCommand,
|
||||
arg.StartCommand,
|
||||
arg.Type,
|
||||
arg.Status,
|
||||
arg.Image,
|
||||
|
||||
@@ -25,8 +25,48 @@ FROM services
|
||||
WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
);
|
||||
|
||||
-- name: UpsertEnvironmentVariable :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
|
||||
Reference in new issue
Block a user