xenfra 0.2.1__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xenfra/engine.py DELETED
@@ -1,292 +0,0 @@
1
- # src/xenfra/engine.py
2
-
3
- import os
4
- import time
5
- from pathlib import Path
6
- import digitalocean
7
- import fabric
8
- from dotenv import load_dotenv
9
- from sqlmodel import Session
10
-
11
- # Xenfra modules
12
- from . import dockerizer
13
- from . import recipes
14
- from .db.models import Project
15
- from .db.session import get_session
16
-
17
- class DeploymentError(Exception):
18
- """Custom exception for deployment failures."""
19
- def __init__(self, message, stage="Unknown"):
20
- self.message = message
21
- self.stage = stage
22
- super().__init__(f"Deployment failed at stage '{stage}': {message}")
23
-
24
- class InfraEngine:
25
- """
26
- The InfraEngine is the core of Xenfra. It handles all interactions
27
- with the cloud provider and orchestrates the deployment lifecycle.
28
- """
29
- def __init__(self, token: str = None, db_session: Session = None):
30
- """
31
- Initializes the engine and validates the API token.
32
- """
33
- load_dotenv()
34
- self.token = token or os.getenv("DIGITAL_OCEAN_TOKEN")
35
- self.db_session = db_session or next(get_session())
36
-
37
- if not self.token:
38
- raise ValueError("DigitalOcean API token not found. Please set the DIGITAL_OCEAN_TOKEN environment variable.")
39
- try:
40
- self.manager = digitalocean.Manager(token=self.token)
41
- self.get_user_info()
42
- except Exception as e:
43
- raise ConnectionError(f"Failed to connect to DigitalOcean: {e}")
44
-
45
- def _get_connection(self, ip_address: str):
46
- """Establishes a Fabric connection to the server."""
47
- private_key_path = str(Path.home() / ".ssh" / "id_rsa")
48
- if not Path(private_key_path).exists():
49
- raise DeploymentError("No private SSH key found at ~/.ssh/id_rsa.", stage="Setup")
50
-
51
- return fabric.Connection(
52
- host=ip_address,
53
- user="root",
54
- connect_kwargs={"key_filename": [private_key_path]},
55
- )
56
-
57
- def get_user_info(self):
58
- """Retrieves user account information."""
59
- return self.manager.get_account()
60
-
61
- def list_servers(self):
62
- """Retrieves a list of all Droplets."""
63
- return self.manager.get_all_droplets()
64
-
65
- def destroy_server(self, droplet_id: int, db_session: Session = None):
66
- """Destroys a Droplet by its ID and removes it from the local DB."""
67
- session = db_session or self.db_session
68
-
69
- # Find the project in the local DB
70
- project_to_delete = session.query(Project).filter(Project.droplet_id == droplet_id).first()
71
-
72
- # Destroy the droplet on DigitalOcean
73
- droplet = digitalocean.Droplet(token=self.token, id=droplet_id)
74
- droplet.destroy()
75
-
76
- # If it was in our DB, delete it
77
- if project_to_delete:
78
- session.delete(project_to_delete)
79
- session.commit()
80
-
81
- def list_projects_from_db(self, db_session: Session = None):
82
- """Lists all projects from the local database."""
83
- session = db_session or self.db_session
84
- return session.query(Project).all()
85
-
86
- def sync_with_provider(self, db_session: Session = None):
87
- """Reconciles the local database with the live state from DigitalOcean."""
88
- session = db_session or self.db_session
89
-
90
- # 1. Get live and local states
91
- live_droplets = self.manager.get_all_droplets(tag_name="xenfra")
92
- local_projects = self.list_projects_from_db(session)
93
-
94
- live_map = {d.id: d for d in live_droplets}
95
- local_map = {p.droplet_id: p for p in local_projects}
96
-
97
- # 2. Reconcile
98
- # Add new servers found on DO to our DB
99
- for droplet_id, droplet in live_map.items():
100
- if droplet_id not in local_map:
101
- new_project = Project(
102
- droplet_id=droplet.id, name=droplet.name, ip_address=droplet.ip_address,
103
- status=droplet.status, region=droplet.region['slug'], size=droplet.size_slug
104
- )
105
- session.add(new_project)
106
-
107
- # Remove servers from our DB that no longer exist on DO
108
- for project_id, project in local_map.items():
109
- if project_id not in live_map:
110
- session.delete(project)
111
-
112
- session.commit()
113
- return self.list_projects_from_db(session)
114
-
115
- def stream_logs(self, droplet_id: int, db_session: Session = None):
116
- """
117
- Verifies a server exists and streams its logs in real-time.
118
- """
119
- session = db_session or self.db_session
120
-
121
- # 1. Find project in local DB
122
- project = session.query(Project).filter(Project.droplet_id == droplet_id).first()
123
- if not project:
124
- raise DeploymentError(f"Project with Droplet ID {droplet_id} not found in local database.", stage="Log Streaming")
125
-
126
- # 2. Just-in-Time Verification
127
- try:
128
- droplet = self.manager.get_droplet(droplet_id)
129
- except digitalocean.baseapi.DataReadError as e:
130
- if e.response.status_code == 404:
131
- # The droplet doesn't exist, so remove it from our DB
132
- session.delete(project)
133
- session.commit()
134
- raise DeploymentError(f"Server '{project.name}' (ID: {droplet_id}) no longer exists on DigitalOcean. It has been removed from your local list.", stage="Log Streaming")
135
- else:
136
- raise e
137
-
138
- # 3. Stream logs
139
- ip_address = droplet.ip_address
140
- with self._get_connection(ip_address) as conn:
141
- conn.run("cd /root/app && docker-compose logs -f app", pty=True)
142
-
143
-
144
-
145
- def _ensure_ssh_key(self, logger):
146
- """Ensures a local public SSH key is on DigitalOcean."""
147
- pub_key_path = Path.home() / ".ssh" / "id_rsa.pub"
148
- if not pub_key_path.exists():
149
- raise DeploymentError("No SSH key found at ~/.ssh/id_rsa.pub. Please generate one.", stage="Setup")
150
-
151
- with open(pub_key_path) as f:
152
- pub_key_content = f.read()
153
-
154
- existing_keys = self.manager.get_all_sshkeys()
155
- for key in existing_keys:
156
- if key.public_key.strip() == pub_key_content.strip():
157
- logger(" - Found existing SSH key on DigitalOcean.")
158
- return key
159
-
160
- logger(" - No matching SSH key found. Creating a new one on DigitalOcean...")
161
- key = digitalocean.SSHKey(token=self.token, name="xenfra-cli-key", public_key=pub_key_content)
162
- key.create()
163
- return key
164
-
165
- def deploy_server(self, name: str, region: str, size: str, image: str, logger: callable, db_session: Session = None, **kwargs):
166
- """A stateful, blocking orchestrator for deploying a new server."""
167
- droplet = None
168
- session = db_session or self.db_session
169
- try:
170
- # === 1. SETUP STAGE ===
171
- logger("\n[bold blue]PHASE 1: SETUP[/bold blue]")
172
- ssh_key = self._ensure_ssh_key(logger)
173
-
174
- # === 2. ASSET GENERATION STAGE ===
175
- logger("\n[bold blue]PHASE 2: GENERATING DEPLOYMENT ASSETS[/bold blue]")
176
- context = {**kwargs} # Pass db config, etc.
177
- files = dockerizer.generate_templated_assets(context)
178
- for file in files:
179
- logger(f" - Generated {file}")
180
-
181
- # === 3. CLOUD-INIT STAGE ===
182
- logger("\n[bold blue]PHASE 3: CREATING SERVER SETUP SCRIPT[/bold blue]")
183
- cloud_init_script = recipes.generate_stack(context)
184
- logger(" - Generated cloud-init script.")
185
-
186
- # === 4. DROPLET CREATION STAGE ===
187
- logger("\n[bold blue]PHASE 4: PROVISIONING SERVER[/bold blue]")
188
- droplet = digitalocean.Droplet(
189
- token=self.token, name=name, region=region, image=image, size_slug=size,
190
- ssh_keys=[ssh_key], userdata=cloud_init_script, tags=["xenfra"]
191
- )
192
- droplet.create()
193
- logger(f" - Droplet '{name}' creation initiated (ID: {droplet.id}). Waiting for it to become active...")
194
-
195
- # === 5. POLLING STAGE ===
196
- logger("\n[bold blue]PHASE 5: WAITING FOR SERVER SETUP[/bold blue]")
197
- while True:
198
- droplet.load()
199
- if droplet.status == 'active':
200
- logger(" - Droplet is active. Waiting for SSH to be available...")
201
- break
202
- time.sleep(10)
203
-
204
- ip_address = droplet.ip_address
205
-
206
- # Retry SSH connection
207
- conn = None
208
- max_retries = 12 # 2-minute timeout for SSH
209
- for i in range(max_retries):
210
- try:
211
- logger(f" - Attempting SSH connection ({i+1}/{max_retries})...")
212
- conn = self._get_connection(ip_address)
213
- conn.open() # Explicitly open the connection
214
- logger(" - SSH connection established.")
215
- break
216
- except Exception as e:
217
- if i < max_retries - 1:
218
- logger(f" - SSH connection failed. Retrying in 10s...")
219
- time.sleep(10)
220
- else:
221
- raise DeploymentError(f"Failed to establish SSH connection: {e}", stage="Polling")
222
-
223
- if not conn or not conn.is_connected:
224
- raise DeploymentError("Could not establish SSH connection.", stage="Polling")
225
-
226
- with conn:
227
- for i in range(30): # 5-minute timeout for cloud-init
228
- if conn.run("test -f /root/setup_complete", warn=True).ok:
229
- logger(" - Cloud-init setup complete.")
230
- break
231
- time.sleep(10)
232
- else:
233
- raise DeploymentError("Server setup script failed to complete in time.", stage="Polling")
234
-
235
- # === 6. CODE UPLOAD STAGE ===
236
- logger("\n[bold blue]PHASE 6: UPLOADING APPLICATION CODE[/bold blue]")
237
- with self._get_connection(ip_address) as conn:
238
- fabric.transfer.Transfer(conn).upload(".", "/root/app", exclude=[".git", ".venv", "__pycache__"])
239
- logger(" - Code upload complete.")
240
-
241
- # === 7. FINAL DEPLOY STAGE ===
242
- logger("\n[bold blue]PHASE 7: BUILDING AND DEPLOYING CONTAINERS[/bold blue]")
243
- with self._get_connection(ip_address) as conn:
244
- result = conn.run("cd /root/app && docker-compose up -d --build", hide=True)
245
- if result.failed:
246
- raise DeploymentError(f"docker-compose failed: {result.stderr}", stage="Deploy")
247
- logger(" - Docker containers are building in the background...")
248
-
249
- # === 8. VERIFICATION STAGE ===
250
- logger("\n[bold blue]PHASE 8: VERIFYING DEPLOYMENT[/bold blue]")
251
- app_port = context.get('port', 8000)
252
- for i in range(24): # 2-minute timeout for health checks
253
- logger(f" - Health check attempt {i+1}/24...")
254
- with self._get_connection(ip_address) as conn:
255
- # Check if container is running
256
- ps_result = conn.run("cd /root/app && docker-compose ps", hide=True)
257
- if "running" not in ps_result.stdout:
258
- time.sleep(5)
259
- continue
260
-
261
- # Check if application is responsive
262
- curl_result = conn.run(f"curl -s --fail http://localhost:{app_port}/", warn=True)
263
- if curl_result.ok:
264
- logger("[bold green] - Health check passed! Application is live.[/bold green]")
265
-
266
- # === 9. PERSISTENCE STAGE ===
267
- logger("\n[bold blue]PHASE 9: SAVING DEPLOYMENT TO DATABASE[/bold blue]")
268
- project = Project(
269
- droplet_id=droplet.id,
270
- name=droplet.name,
271
- ip_address=ip_address,
272
- status=droplet.status,
273
- region=droplet.region['slug'],
274
- size=droplet.size_slug,
275
- )
276
- session.add(project)
277
- session.commit()
278
- logger(" - Deployment saved.")
279
-
280
- return droplet # Return the full droplet object
281
- time.sleep(5)
282
- else:
283
- # On failure, get logs and destroy droplet
284
- with self._get_connection(ip_address) as conn:
285
- logs = conn.run("cd /root/app && docker-compose logs", hide=True).stdout
286
- raise DeploymentError(f"Application failed to become healthy in time. Logs:\n{logs}", stage="Verification")
287
-
288
- except Exception as e:
289
- if droplet:
290
- logger(f"[bold red]Deployment failed. The server '{droplet.name}' will NOT be cleaned up for debugging purposes.[/bold red]")
291
- # droplet.destroy() # Commented out for debugging
292
- raise e
xenfra/mcp_client.py DELETED
@@ -1,149 +0,0 @@
1
- # src/xenfra/mcp_client.py
2
-
3
- import json
4
- import subprocess
5
- import os
6
- import tempfile
7
- import base64
8
- from pathlib import Path
9
-
10
- class MCPClient:
11
- """
12
- A client for communicating with a local github-mcp-server process.
13
-
14
- This client starts the MCP server as a subprocess and interacts with it
15
- over stdin and stdout to download a full repository to a temporary directory.
16
- """
17
-
18
- def __init__(self, mcp_server_path="github-mcp-server"):
19
- """
20
- Initializes the MCPClient.
21
-
22
- Args:
23
- mcp_server_path (str): The path to the github-mcp-server executable.
24
- Assumes it's in the system's PATH by default.
25
- """
26
- self.mcp_server_path = mcp_server_path
27
- self.process = None
28
-
29
- def _start_server(self):
30
- """Starts the github-mcp-server subprocess."""
31
- if self.process and self.process.poll() is None:
32
- return
33
- try:
34
- self.process = subprocess.Popen(
35
- [self.mcp_server_path],
36
- stdin=subprocess.PIPE,
37
- stdout=subprocess.PIPE,
38
- stderr=subprocess.PIPE,
39
- text=True,
40
- env=os.environ
41
- )
42
- except FileNotFoundError:
43
- raise RuntimeError(f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH.")
44
- except Exception as e:
45
- raise RuntimeError(f"Failed to start MCP server: {e}")
46
-
47
- def _stop_server(self):
48
- """Stops the MCP server process."""
49
- if self.process:
50
- self.process.terminate()
51
- self.process.wait()
52
- self.process = None
53
-
54
- def _send_request(self, method: str, params: dict) -> dict:
55
- """Sends a JSON-RPC request and returns the response."""
56
- if not self.process or self.process.poll() is not None:
57
- self._start_server()
58
-
59
- request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
60
-
61
- try:
62
- self.process.stdin.write(json.dumps(request) + '\n')
63
- self.process.stdin.flush()
64
- response_line = self.process.stdout.readline()
65
- if not response_line:
66
- error_output = self.process.stderr.read()
67
- raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
68
-
69
- response = json.loads(response_line)
70
- if "error" in response:
71
- raise RuntimeError(f"MCP server returned an error: {response['error']}")
72
- return response.get("result", {})
73
- except (BrokenPipeError, json.JSONDecodeError) as e:
74
- raise RuntimeError(f"Failed to communicate with MCP server: {e}")
75
-
76
- def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
77
- """
78
- Downloads an entire repository at a specific commit to a local temporary directory.
79
-
80
- Args:
81
- repo_url (str): The full URL of the GitHub repository.
82
- commit_sha (str): The commit SHA to download. Defaults to "HEAD".
83
-
84
- Returns:
85
- The path to the temporary directory containing the downloaded code.
86
- """
87
- try:
88
- parts = repo_url.strip('/').split('/')
89
- owner = parts[-2]
90
- repo_name = parts[-1].replace('.git', '')
91
- except IndexError:
92
- raise ValueError("Invalid repository URL format. Expected format: https://github.com/owner/repo")
93
-
94
- print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
95
- tree_result = self._send_request(
96
- method="git.get_repository_tree",
97
- params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True}
98
- )
99
-
100
- tree = tree_result.get("tree", [])
101
- if not tree:
102
- raise RuntimeError("Could not retrieve repository file tree.")
103
-
104
- temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
105
- print(f" [MCP] Downloading to temporary directory: {temp_dir}")
106
-
107
- for item in tree:
108
- item_path = item.get("path")
109
- item_type = item.get("type")
110
-
111
- if not item_path or item_type != "blob": # Only handle files
112
- continue
113
-
114
- # For downloading content, we can use the commit_sha in the 'ref' parameter
115
- # to ensure we get the content from the correct version.
116
- content_result = self._send_request(
117
- method="repos.get_file_contents",
118
- params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha}
119
- )
120
-
121
- content_b64 = content_result.get("content")
122
- if content_b64 is None:
123
- print(f" [MCP] [Warning] Could not get content for {item_path}")
124
- continue
125
-
126
- try:
127
- # Content is base64 encoded, with newlines.
128
- decoded_content = base64.b64decode(content_b64.replace('\n', ''))
129
- except (base64.binascii.Error, TypeError):
130
- print(f" [MCP] [Warning] Could not decode content for {item_path}")
131
- continue
132
-
133
- # Create file and parent directories in the temp location
134
- local_file_path = Path(temp_dir) / item_path
135
- local_file_path.parent.mkdir(parents=True, exist_ok=True)
136
-
137
- # Write the file content
138
- with open(local_file_path, "wb") as f:
139
- f.write(decoded_content)
140
-
141
- print(" [MCP] ✅ Repository download complete.")
142
- return temp_dir
143
-
144
- def __enter__(self):
145
- self._start_server()
146
- return self
147
-
148
- def __exit__(self, exc_type, exc_val, exc_tb):
149
- self._stop_server()
xenfra/models.py DELETED
@@ -1,54 +0,0 @@
1
- from enum import Enum
2
- from datetime import datetime
3
- from pydantic import BaseModel, Field
4
-
5
- class DeploymentStatus(str, Enum):
6
- PENDING = "pending"
7
- IN_PROGRESS = "in_progress"
8
- SUCCESS = "success"
9
- FAILED = "failed"
10
-
11
- class SourceType(str, Enum):
12
- LOCAL = "local"
13
- GIT = "git"
14
-
15
- class Deployment(BaseModel):
16
- id: str = Field(..., description="Unique identifier for the deployment")
17
- projectId: str = Field(..., description="Identifier of the project being deployed")
18
- status: DeploymentStatus = Field(..., description="Current status of the deployment")
19
- source: str = Field(..., description="Source of the deployment (e.g., 'cli', 'api')")
20
- created_at: datetime = Field(..., description="Timestamp when the deployment was created")
21
- finished_at: datetime | None = Field(None, description="Timestamp when the deployment finished")
22
-
23
- class DeploymentRecord(BaseModel):
24
- deployment_id: str = Field(..., description="Unique identifier for this deployment instance.")
25
- timestamp: datetime = Field(..., description="Timestamp of when the deployment succeeded.")
26
- source_type: SourceType = Field(..., description="The type of the source code (local or git).")
27
- source_identifier: str = Field(..., description="The identifier for the source (commit SHA for git, archive path for local).")
28
-
29
- class BalanceRead(BaseModel):
30
- month_to_date_balance: str
31
- account_balance: str
32
- month_to_date_usage: str
33
- generated_at: str
34
- error: str | None = None
35
-
36
- class DropletCostRead(BaseModel):
37
- id: int
38
- name: str
39
- ip_address: str
40
- status: str
41
- size_slug: str
42
- monthly_price: float
43
-
44
- class ProjectRead(BaseModel):
45
- id: int
46
- name: str
47
- ip_address: str | None = None
48
- status: str
49
- region: str
50
- size_slug: str
51
- estimated_monthly_cost: float | None = None
52
- created_at: datetime
53
-
54
-
xenfra/recipes.py DELETED
@@ -1,23 +0,0 @@
1
- from jinja2 import Environment, FileSystemLoader
2
- from pathlib import Path
3
-
4
- def generate_stack(context: dict):
5
- """
6
- Generates a cloud-init startup script from a Jinja2 template.
7
-
8
- Args:
9
- context: A dictionary containing information for rendering the template,
10
- e.g., {'domain': 'example.com', 'email': 'user@example.com'}
11
- """
12
- # Path to the templates directory
13
- template_dir = Path(__file__).parent / "templates"
14
- env = Environment(loader=FileSystemLoader(template_dir))
15
-
16
- template = env.get_template("cloud-init.sh.j2")
17
-
18
- # The non-dockerized logic has been removed as we are focusing on
19
- # a purely Docker-based deployment strategy for simplicity and scalability.
20
- # The context will contain all necessary variables for the template.
21
- script = template.render(context)
22
-
23
- return script
xenfra/security.py DELETED
@@ -1,58 +0,0 @@
1
- # src/xenfra/security.py
2
-
3
- from datetime import datetime, timedelta, timezone
4
- from typing import Optional
5
- from passlib.context import CryptContext
6
- from jose import JWTError, jwt
7
- from cryptography.fernet import Fernet
8
-
9
- from xenfra.config import settings
10
-
11
- # --- Configuration ---
12
- SECRET_KEY = settings.SECRET_KEY
13
- ALGORITHM = "HS256"
14
- ACCESS_TOKEN_EXPIRE_MINUTES = 30
15
-
16
- # This key MUST be 32 url-safe base64-encoded bytes.
17
- fernet = Fernet(settings.ENCRYPTION_KEY.encode())
18
-
19
- # --- Password Hashing ---
20
- # Explicitly use passlib.backends.bcrypt
21
- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
22
-
23
- def verify_password(plain_password: str, hashed_password: str) -> bool:
24
- return pwd_context.verify(plain_password, hashed_password)
25
-
26
- def get_password_hash(password: str) -> str:
27
- # bcrypt passwords cannot be longer than 72 bytes. Truncate if necessary.
28
- # Note: Frontend should also enforce password length limits.
29
- if len(password.encode('utf-8')) > 72:
30
- password = password[:72]
31
- return pwd_context.hash(password)
32
-
33
- # --- JWT Handling ---
34
- def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
35
- to_encode = data.copy()
36
- if expires_delta:
37
- expire = datetime.now(timezone.utc) + expires_delta
38
- else:
39
- expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
40
- to_encode.update({"exp": expire})
41
- encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
42
- return encoded_jwt
43
-
44
- def decode_token(token: str) -> Optional[dict]:
45
- try:
46
- payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
47
- return payload
48
- except JWTError:
49
- return None
50
-
51
- # --- Token Encryption ---
52
- def encrypt_token(token: str) -> str:
53
- """Encrypts a token using Fernet symmetric encryption."""
54
- return fernet.encrypt(token.encode()).decode()
55
-
56
- def decrypt_token(encrypted_token: str) -> str:
57
- """Decrypts a token."""
58
- return fernet.decrypt(encrypted_token.encode()).decode()
@@ -1,25 +0,0 @@
1
- # Dockerfile template for Python web applications
2
- FROM {{ python_version | default('python:3.11-slim') }}
3
-
4
- WORKDIR /app
5
-
6
- # Install uv, our preferred package manager
7
- RUN apt-get update && apt-get install -y curl && \
8
- curl -LsSf https://astral.sh/uv/install.sh | sh && \
9
- apt-get remove -y curl && \
10
- apt-get clean && \
11
- rm -rf /var/lib/apt/lists/*
12
-
13
- COPY requirements.txt .
14
-
15
- # Install dependencies
16
- RUN /root/.cargo/bin/uv pip install --system --no-cache -r requirements.txt
17
-
18
- COPY . .
19
-
20
- # Expose the application port
21
- EXPOSE {{ port | default(8000) }}
22
-
23
- # The command to run the application will be in docker-compose.yml
24
- # This allows for more flexibility
25
-
@@ -1,68 +0,0 @@
1
- #!/bin/bash
2
- export DEBIAN_FRONTEND=noninteractive
3
- LOG="/root/setup.log"
4
- touch $LOG
5
-
6
- echo "--------------------------------" >> $LOG
7
- echo "🧘 XENFRA: Context-Aware Boot" >> $LOG
8
- echo "--------------------------------" >> $LOG
9
-
10
- # Create App Directory
11
- mkdir -p /root/app
12
- cd /root/app
13
-
14
- # --- AGGRESSIVE FIX: KILL BACKGROUND UPDATES ---
15
- echo "⚔️ [0/6] Stopping Background Updates..." >> $LOG
16
- systemctl stop unattended-upgrades.service || true
17
- systemctl stop apt-daily.service || true
18
- systemctl stop apt-daily-upgrade.service || true
19
- systemctl kill --kill-who=all apt-daily.service || true
20
- systemctl kill --kill-who=all apt-daily-upgrade.service || true
21
-
22
- # Force remove locks if they exist
23
- rm -f /var/lib/dpkg/lock*
24
- rm -f /var/lib/apt/lists/lock
25
- rm -f /var/cache/apt/archives/lock
26
- dpkg --configure -a || true
27
- # -----------------------------------------------
28
-
29
- # 1. System Updates
30
- echo "🔄 [1/5] Refreshing Package Lists..." >> $LOG
31
- apt-get update
32
- apt-get install -y python3-pip git curl
33
-
34
- # 2. Install Docker & Compose
35
- echo "🐳 [2/5] Installing Docker..." >> $LOG
36
- apt-get install -y docker.io || (curl -fsSL https://get.docker.com | sh)
37
- echo "🎶 [3/5] Installing Docker Compose..." >> $LOG
38
- apt-get install -y docker-compose-v2
39
-
40
- # --- DOCKERIZED DEPLOYMENT ---
41
- echo "📦 [4/5] Installing Caddy..." >> $LOG
42
- apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
43
- curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
44
- curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
45
- apt-get update
46
- apt-get install -y caddy
47
-
48
- {% if domain %}
49
- # Dynamically generate Caddyfile content
50
- echo "🔒 Writing Caddyfile for {{ domain }}..." >> $LOG
51
- cat << EOF > /etc/caddy/Caddyfile
52
- {{ domain }}:80, {{ domain }}:443 {
53
- reverse_proxy localhost:{{ port | default(8000) }}
54
- tls {{ email }}
55
- }
56
- EOF
57
- {% endif %}
58
-
59
- {% if domain %}
60
- echo "🚀 [5/5] Starting Caddy..." >> $LOG
61
- systemctl restart caddy
62
- {% else %}
63
- echo "✅ [5/5] Skipping Caddy start (no domain specified)." >> $LOG
64
- {% endif %}
65
-
66
- # Finish
67
- echo "✅ SETUP SCRIPT COMPLETE" >> $LOG
68
- touch /root/setup_complete